@navikt/ds-react
Version:
React components from the Norwegian Labour and Welfare Administration.
318 lines (276 loc) • 9.84 kB
text/typescript
import { isIOS, isWebKit } from "../detectBrowser";
import { ownerDocument, ownerWindow } from "../owner";
import { useClientLayoutEffect } from "./useClientLayoutEffect";
import { Timeout } from "./useTimeout";
let originalHtmlStyles: Partial<CSSStyleDeclaration> = {};
let originalBodyStyles: Partial<CSSStyleDeclaration> = {};
let originalHtmlScrollBehavior = "";
function hasInsetScrollbars(referenceElement: Element | null) {
if (typeof document === "undefined") {
return false;
}
const doc = ownerDocument(referenceElement);
const win = ownerWindow(doc);
return win.innerWidth - doc.documentElement.clientWidth > 0;
}
function preventScrollBasic(referenceElement: Element | null) {
const doc = ownerDocument(referenceElement);
const html = doc.documentElement;
const originalOverflow = html.style.overflow;
html.style.overflow = "hidden";
return () => {
html.style.overflow = originalOverflow;
};
}
function preventScrollStandard(referenceElement: Element | null) {
const doc = ownerDocument(referenceElement);
const html = doc.documentElement;
const body = doc.body;
const win = 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 (isWebKit && (win.visualViewport?.scale ?? 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: */
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" &&
CSS.supports?.("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 {
lockCount = 0;
restore: (() => void) | null = null;
timeoutLock = Timeout.create();
timeoutUnlock = Timeout.create();
/**
* Aquires a new lock
* - If first lock, lock document-scroll.
* - If not first lock, do nothing.
*/
acquire(referenceElement: Element | null) {
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;
}
/**
* Releases a lock
* - If last lock, unlock document-scroll.
* - If not last lock, do nothing.
*/
release = () => {
this.lockCount -= 1;
if (this.lockCount === 0 && this.restore) {
this.timeoutUnlock.start(0, this.unlock);
}
};
private unlock = () => {
if (this.lockCount === 0 && this.restore) {
this.restore?.();
this.restore = null;
}
};
private lock(referenceElement: Element | null) {
if (this.lockCount === 0 || this.restore !== null) {
return;
}
const doc = ownerDocument(referenceElement);
const html = doc.documentElement;
const htmlOverflowY = 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 = 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: {
enabled: boolean;
mounted: boolean;
open: boolean;
referenceElement?: Element | null;
}) {
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
*/
useClientLayoutEffect(() => {
if (enabled && isWebKit && mounted && !open) {
const doc = 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]);
useClientLayoutEffect(() => {
if (!enabled) {
return undefined;
}
return SCROLL_LOCKER.acquire(referenceElement);
}, [enabled, referenceElement]);
}
export { useScrollLock };