UNPKG

@navikt/ds-react

Version:

React components from the Norwegian Labour and Welfare Administration.

284 lines 11.9 kB
"use strict"; 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