Files
tools/vue/primitives/src/overlays/drawer/usePositionFixed.ts
T
robonen 85313c6046
Publish to NPM / Check version changes and publish (push) Successful in 11m14s
feat: update useSnapPoints to improve drawer snapping behavior and add new features
2026-08-03 21:12:35 +07:00

129 lines
4.0 KiB
TypeScript

import type { Ref } from 'vue';
import { ref, watch } from 'vue';
import { isSafari } from '@robonen/platform/browsers';
import { useEventListener, useMediaQuery } from '@robonen/vue';
interface BodyPosition {
position: string;
top: string;
left: string;
height: string;
}
interface PositionFixedOptions {
isOpen: Ref<boolean>;
modal: Ref<boolean>;
nested: Ref<boolean>;
hasBeenOpened: Ref<boolean>;
preventScrollRestoration: Ref<boolean>;
noBodyStyles: Ref<boolean>;
}
// Module-level so a single restoration survives across nested drawers that each
// run this composable — only the outermost open/close should touch the body.
let previousBodyPosition: BodyPosition | null = null;
/**
* Pins `document.body` with `position: fixed` while the drawer is open on iOS
* Safari, where the address bar otherwise causes a jarring viewport shift. A
* no-op on every other browser. Restores the scroll position on close.
*/
export function usePositionFixed(options: PositionFixedOptions) {
const { isOpen, modal, nested, hasBeenOpened, preventScrollRestoration, noBodyStyles } = options;
const activeUrl = ref(globalThis.window !== undefined ? globalThis.location.href : '');
const scrollPos = ref(0);
// Standalone PWAs have no address bar to fight (SSR-safe via useMediaQuery).
const isStandalone = useMediaQuery('(display-mode: standalone)');
function setPositionFixed(): void {
if (!isSafari())
return;
if (previousBodyPosition === null && isOpen.value && !noBodyStyles.value) {
previousBodyPosition = {
position: document.body.style.position,
top: document.body.style.top,
left: document.body.style.left,
height: document.body.style.height,
};
const { scrollX, innerHeight } = globalThis;
document.body.style.setProperty('position', 'fixed', 'important');
Object.assign(document.body.style, {
top: `${-scrollPos.value}px`,
left: `${-scrollX}px`,
right: '0px',
height: 'auto',
});
setTimeout(() => {
requestAnimationFrame(() => {
// If a bottom bar appeared after pinning, nudge the content up so it
// isn't hidden behind it.
const bottomBarHeight = innerHeight - window.innerHeight;
if (bottomBarHeight && scrollPos.value >= innerHeight)
document.body.style.top = `-${scrollPos.value + bottomBarHeight}px`;
});
}, 300);
}
}
function restorePositionSetting(): void {
if (!isSafari())
return;
if (previousBodyPosition !== null && !noBodyStyles.value) {
const y = -Number.parseInt(document.body.style.top, 10);
const x = -Number.parseInt(document.body.style.left, 10);
Object.assign(document.body.style, previousBodyPosition);
requestAnimationFrame(() => {
if (preventScrollRestoration.value && activeUrl.value !== globalThis.location.href) {
activeUrl.value = globalThis.location.href;
return;
}
window.scrollTo(x, y);
});
previousBodyPosition = null;
}
}
function onScroll() {
scrollPos.value = window.scrollY;
}
// Capture the position at setup (the page may already be scrolled), then keep
// it in sync. `useEventListener` defaults to `defaultWindow` and auto-removes.
if (globalThis.window !== undefined)
onScroll();
useEventListener('scroll', onScroll, { passive: true });
// `activeUrl` is read inside restorePositionSetting's rAF to detect navigation,
// but it must NOT drive this watch — it mutates on close and would re-fire the
// (idempotent) handler once for nothing.
watch([isOpen, hasBeenOpened], () => {
if (nested.value || !hasBeenOpened.value)
return;
if (isOpen.value) {
if (!isStandalone.value)
setPositionFixed();
if (!modal.value) {
setTimeout(() => {
restorePositionSetting();
}, 500);
}
}
else {
restorePositionSetting();
}
});
return { restorePositionSetting };
}