@navikt/ds-react
Version:
React components from the Norwegian Labour and Welfare Administration.
284 lines • 11.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.useScrollLock = useScrollLock;
const detectBrowser_1 = require("../detectBrowser");
const owner_1 = require("../owner");
const useClientLayoutEffect_1 = require("./useClientLayoutEffect");
const useTimeout_1 = require("./useTimeout");
let originalHtmlStyles = {};
let originalBodyStyles = {};
let originalHtmlScrollBehavior = "";
function hasInsetScrollbars(referenceElement) {
if (typeof document === "undefined") {
return false;
}
const doc = (0, owner_1.ownerDocument)(referenceElement);
const win = (0, owner_1.ownerWindow)(doc);
return win.innerWidth - doc.documentElement.clientWidth > 0;
}
function preventScrollBasic(referenceElement) {
const doc = (0, owner_1.ownerDocument)(referenceElement);
const html = doc.documentElement;
const body = doc.body;
/**
* If an `overflow` style is present on <html>, we need to lock it, because a lock on <body>
* won't have any effect.
* But if <body> has an `overflow` style (like `overflow-x: hidden`), we need to lock it
* instead, as sticky elements shift otherwise.
*/
const elementToLock = isOverflowElement(html) ? html : body;
const originalOverflow = elementToLock.style.overflow;
elementToLock.style.overflow = "hidden";
return () => {
elementToLock.style.overflow = originalOverflow;
};
}
function preventScrollStandard(referenceElement) {
var _a, _b;
const doc = (0, owner_1.ownerDocument)(referenceElement);
const html = doc.documentElement;
const body = doc.body;
const win = (0, owner_1.ownerWindow)(html);
let scrollTop = 0;
let scrollLeft = 0;
let resizeRaf = 0;
/* Pinch-zoom in Safari causes a shift. Just don't lock scroll if there's any pinch-zoom. */
if (detectBrowser_1.isWebKit && ((_b = (_a = win.visualViewport) === null || _a === void 0 ? void 0 : _a.scale) !== null && _b !== void 0 ? _b : 1) !== 1) {
return () => { };
}
/**
* Locks the scroll by applying styles to Html and Body element.
* Reads the DOM first, then writes to avoid layout thrashing.
*/
function lockScroll() {
/* DOM reads: */
var _a;
const htmlStyles = win.getComputedStyle(html);
const bodyStyles = win.getComputedStyle(body);
scrollTop = html.scrollTop;
scrollLeft = html.scrollLeft;
originalHtmlStyles = {
scrollbarGutter: html.style.scrollbarGutter,
overflowY: html.style.overflowY,
overflowX: html.style.overflowX,
};
originalHtmlScrollBehavior = html.style.scrollBehavior;
originalBodyStyles = {
position: body.style.position,
height: body.style.height,
width: body.style.width,
boxSizing: body.style.boxSizing,
overflowY: body.style.overflowY,
overflowX: body.style.overflowX,
scrollBehavior: body.style.scrollBehavior,
};
const isScrollableY = html.scrollHeight > html.clientHeight;
const isScrollableX = html.scrollWidth > html.clientWidth;
const hasConstantOverflowY = htmlStyles.overflowY === "scroll" || bodyStyles.overflowY === "scroll";
const hasConstantOverflowX = htmlStyles.overflowX === "scroll" || bodyStyles.overflowX === "scroll";
/* Values can be negative in Firefox */
const scrollbarWidth = Math.max(0, win.innerWidth - html.clientWidth);
const scrollbarHeight = Math.max(0, win.innerHeight - html.clientHeight);
/*
* Avoid shift due to <body> margin. NB: This does cause elements to be clipped
* with whitespace.
*/
const marginY = parseFloat(bodyStyles.marginTop) + parseFloat(bodyStyles.marginBottom);
const marginX = parseFloat(bodyStyles.marginLeft) + parseFloat(bodyStyles.marginRight);
/**
* Check support for stable scrollbar gutter to avoid layout shift when scrollbars appear/disappear.
*/
const supportsStableScrollbarGutter = typeof CSS !== "undefined" &&
((_a = CSS.supports) === null || _a === void 0 ? void 0 : _a.call(CSS, "scrollbar-gutter", "stable"));
/*
* DOM writes:
* Do not read the DOM past this point!
*/
Object.assign(html.style, {
scrollbarGutter: "stable",
overflowY: !supportsStableScrollbarGutter &&
(isScrollableY || hasConstantOverflowY)
? "scroll"
: "hidden",
overflowX: !supportsStableScrollbarGutter &&
(isScrollableX || hasConstantOverflowX)
? "scroll"
: "hidden",
});
Object.assign(body.style, {
/*
* Keeps existing positioned children in place (e.g. fixed headers).
*/
position: "relative",
/**
* Limits height to the viewport minus margins/scrollbar compensation to stop vertical overflow from reappearing.
*/
height: marginY || scrollbarHeight
? `calc(100dvh - ${marginY + scrollbarHeight}px)`
: "100dvh",
/**
* Mirrors height-logic for width.
*/
width: marginX || scrollbarWidth
? `calc(100vw - ${marginX + scrollbarWidth}px)`
: "100vw",
/**
* Ensures the adjusted dimensions include padding/border, matching the measured values.
*/
boxSizing: "border-box",
/**
* Blocks scrollable overflow.
*/
overflow: "hidden",
/**
* Removes smooth-scrolling so immediate position restores occur without animation.
*/
scrollBehavior: "unset",
});
body.scrollTop = scrollTop;
body.scrollLeft = scrollLeft;
html.setAttribute("data-aksel-scroll-locked", "");
html.style.scrollBehavior = "unset";
}
/**
* Restores the original scroll position and styles to Html and Body element.
*/
function cleanup() {
Object.assign(html.style, originalHtmlStyles);
Object.assign(body.style, originalBodyStyles);
html.scrollTop = scrollTop;
html.scrollLeft = scrollLeft;
html.removeAttribute("data-aksel-scroll-locked");
html.style.scrollBehavior = originalHtmlScrollBehavior;
}
/**
* On resize, restore original styles, then re-apply scroll lock next frame.
*/
function handleResize() {
cleanup();
if (resizeRaf) {
cancelAnimationFrame(resizeRaf);
}
/**
* Wait until next frame to re-apply scroll lock ensuring layout has settled after resize.
*/
resizeRaf = requestAnimationFrame(lockScroll);
}
lockScroll();
win.addEventListener("resize", handleResize);
return () => {
if (resizeRaf) {
cancelAnimationFrame(resizeRaf);
}
cleanup();
win.removeEventListener("resize", handleResize);
};
}
class ScrollLocker {
constructor() {
this.lockCount = 0;
this.restore = null;
this.timeoutLock = useTimeout_1.Timeout.create();
this.timeoutUnlock = useTimeout_1.Timeout.create();
/**
* Releases a lock
* - If last lock, unlock document-scroll.
* - If not last lock, do nothing.
*/
this.release = () => {
this.lockCount -= 1;
if (this.lockCount === 0 && this.restore) {
this.timeoutUnlock.start(0, this.unlock);
}
};
this.unlock = () => {
var _a;
if (this.lockCount === 0 && this.restore) {
(_a = this.restore) === null || _a === void 0 ? void 0 : _a.call(this);
this.restore = null;
}
};
}
/**
* Aquires a new lock
* - If first lock, lock document-scroll.
* - If not first lock, do nothing.
*/
acquire(referenceElement) {
this.lockCount += 1;
if (this.lockCount === 1 && this.restore === null) {
/*
* Delay locking to avoid layout thrashing when multiple locks/unlocks are requested in quick succession.
*/
this.timeoutLock.start(0, () => this.lock(referenceElement));
}
return this.release;
}
lock(referenceElement) {
if (this.lockCount === 0 || this.restore !== null) {
return;
}
const doc = (0, owner_1.ownerDocument)(referenceElement);
const html = doc.documentElement;
const htmlOverflowY = (0, owner_1.ownerWindow)(html).getComputedStyle(html).overflowY;
/* If the site author already hid overflow on <html>, respect it and bail out. */
if (htmlOverflowY === "hidden" || htmlOverflowY === "clip") {
this.restore = () => { };
return;
}
const shouldUseBasicLock = detectBrowser_1.isIOS || !hasInsetScrollbars(referenceElement);
/**
* On iOS, the standard scroll locking method does not work properly if the navbar is collapsed.
* The following must be researched extensively before activating standard scroll locking on iOS:
* - Textboxes must scroll into view when focused, and not cause a glitchy scroll animation.
* - The navbar must not force itself into view and cause layout shift.
* - Scroll containers must not flicker upon closing a popup when it has an exit animation.
*/
this.restore = shouldUseBasicLock
? preventScrollBasic(referenceElement)
: preventScrollStandard(referenceElement);
}
}
const SCROLL_LOCKER = new ScrollLocker();
/**
* Locks the scroll of the document when enabled.
* @param enabled - Whether to enable the scroll lock.
*/
function useScrollLock(params) {
const { enabled = true, mounted, open, referenceElement = null } = params;
/**
* When closing elements with "sloppy clicks" (clicks that start inside the element and ends outside),
* animating out on WebKit browsers (mounted + not open) can cause the whole page to be selected.
* To prevent this, we temporarily disable user-select on body while the element is animating out.
* This bug might be fixed in newer WebKit versions.
*
* @see https://github.com/mui/base-ui/issues/1135
*/
(0, useClientLayoutEffect_1.useClientLayoutEffect)(() => {
if (enabled && detectBrowser_1.isWebKit && mounted && !open) {
const doc = (0, owner_1.ownerDocument)(referenceElement);
const originalUserSelect = doc.body.style.userSelect;
const originalWebkitUserSelect = doc.body.style.webkitUserSelect;
doc.body.style.userSelect = "none";
doc.body.style.webkitUserSelect = "none";
return () => {
doc.body.style.userSelect = originalUserSelect;
doc.body.style.webkitUserSelect = originalWebkitUserSelect;
};
}
return undefined;
}, [enabled, mounted, open, referenceElement]);
(0, useClientLayoutEffect_1.useClientLayoutEffect)(() => {
if (!enabled) {
return undefined;
}
return SCROLL_LOCKER.acquire(referenceElement);
}, [enabled, referenceElement]);
}
const invalidOverflowDisplayValues = new Set(["inline", "contents"]);
function isOverflowElement(element) {
const { overflow, overflowX, overflowY, display } = getComputedStyle(element);
return (/auto|scroll|overlay|hidden|clip/.test(overflow + overflowY + overflowX) &&
!invalidOverflowDisplayValues.has(display));
}
//# sourceMappingURL=useScrollLock.js.map