From da2d2af8f2fd0bac3c18e2130ab7b354d4e60b24 Mon Sep 17 00:00:00 2001 From: Emil Kowalski Date: Sun, 22 Sep 2024 18:03:13 +0200 Subject: [PATCH 1/8] Add --- src/index.tsx | 19 ++- src/use-position-fixed.ts | 134 ++++++++++++++++++ test/src/app/with-snap-points/page.tsx | 2 +- .../app/without-scaled-background/page.tsx | 5 +- 4 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 src/use-position-fixed.ts diff --git a/src/index.tsx b/src/index.tsx index 72af83a9..a8a41ed0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -21,6 +21,7 @@ import { import { DrawerDirection } from './types'; import { useControllableState } from './use-controllable-state'; import { useScaleBackground } from './use-scale-background'; +import { usePositionFixed } from './use-position-fixed'; export interface WithFadeFromProps { snapPoints: (number | string)[]; @@ -58,6 +59,7 @@ export type DialogProps = { snapToSequentialPoint?: boolean; container?: HTMLElement | null; onAnimationEnd?: (open: boolean) => void; + preventScrollRestoration?: boolean; } & (WithFadeFromProps | WithoutFadeFromProps); export function Root({ @@ -79,11 +81,13 @@ export function Root({ fixed, modal = true, onClose, + nested, noBodyStyles, direction = 'bottom', defaultOpen = false, disablePreventScroll = true, snapToSequentialPoint = false, + preventScrollRestoration = false, repositionInputs = true, onAnimationEnd, container, @@ -94,6 +98,10 @@ export function Root({ onChange: (o: boolean) => { onOpenChange?.(o); + if (!o) { + restorePositionSetting(); + } + setTimeout(() => { onAnimationEnd?.(o); }, TRANSITIONS.DURATION * 1000); @@ -154,6 +162,15 @@ export function Root({ !isOpen || isDragging || !modal || justReleased || !hasBeenOpened || !repositionInputs || !disablePreventScroll, }); + const { restorePositionSetting } = usePositionFixed({ + isOpen, + modal, + nested, + hasBeenOpened, + preventScrollRestoration, + noBodyStyles, + }); + function getScale() { return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth; } @@ -867,7 +884,7 @@ export const Handle = React.forwardRef(function ( } const isLastSnapPoint = activeSnapPoint === snapPoints[snapPoints.length - 1]; - + if (isLastSnapPoint && dismissible) { closeDrawer(); return; diff --git a/src/use-position-fixed.ts b/src/use-position-fixed.ts new file mode 100644 index 00000000..4df9ddd2 --- /dev/null +++ b/src/use-position-fixed.ts @@ -0,0 +1,134 @@ +import React from 'react'; +import { isSafari } from './use-prevent-scroll'; + +let previousBodyPosition: Record | null = null; + +/** + * This hook is necessary to prevent buggy behavior on iOS devices (need to test on Android). + * I won't get into too much detail about what bugs it solves, but so far I've found that setting the body to `position: fixed` is the most reliable way to prevent those bugs. + * Issues that this hook solves: + * https://github.com/emilkowalski/vaul/issues/435 + * https://github.com/emilkowalski/vaul/issues/433 + * And more that I discovered, but were just not reported. + */ + +export function usePositionFixed({ + isOpen, + modal, + nested, + hasBeenOpened, + preventScrollRestoration, + noBodyStyles, +}: { + isOpen: boolean; + modal: boolean; + nested: boolean; + hasBeenOpened: boolean; + preventScrollRestoration: boolean; + noBodyStyles: boolean; +}) { + const [activeUrl, setActiveUrl] = React.useState(() => (typeof window !== 'undefined' ? window.location.href : '')); + const scrollPos = React.useRef(0); + + const previousBodyPosition = React.useMemo(() => { + return { + position: document.body.style.position, + top: document.body.style.top, + left: document.body.style.left, + height: document.body.style.height, + right: 'unset', + }; + }, [isOpen]); + + const setPositionFixed = React.useCallback(() => { + // All browsers on iOS will return true here. + if (!isSafari()) return; + + // If previousBodyPosition is already set, don't set it again. + if (previousBodyPosition === null && isOpen && !noBodyStyles) { + // Update the dom inside an animation frame + const { scrollX, innerHeight } = window; + + document.body.style.setProperty('position', 'fixed', 'important'); + Object.assign(document.body.style, { + top: `${-scrollPos.current}px`, + left: `${-scrollX}px`, + right: '0px', + height: 'auto', + }); + + window.setTimeout( + () => + window.requestAnimationFrame(() => { + // Attempt to check if the bottom bar appeared due to the position change + const bottomBarHeight = innerHeight - window.innerHeight; + if (bottomBarHeight && scrollPos.current >= innerHeight) { + // Move the content further up so that the bottom bar doesn't hide it + document.body.style.top = `${-(scrollPos.current + bottomBarHeight)}px`; + } + }), + 300, + ); + } + }, [isOpen]); + + const restorePositionSetting = React.useCallback(() => { + // All browsers on iOS will return true here. + if (!isSafari()) return; + + if (previousBodyPosition !== null && !noBodyStyles) { + // Convert the position from "px" to Int + const y = -parseInt(document.body.style.top, 10); + const x = -parseInt(document.body.style.left, 10); + console.log(previousBodyPosition); + + // Restore styles + Object.assign(document.body.style, previousBodyPosition); + + window.requestAnimationFrame(() => { + if (preventScrollRestoration && activeUrl !== window.location.href) { + setActiveUrl(window.location.href); + return; + } + + window.scrollTo(x, y); + }); + + previousBodyPosition = null; + } + }, [activeUrl]); + + React.useEffect(() => { + function onScroll() { + scrollPos.current = window.scrollY; + } + + onScroll(); + + window.addEventListener('scroll', onScroll); + + return () => { + window.removeEventListener('scroll', onScroll); + }; + }, []); + + React.useEffect(() => { + if (nested || !hasBeenOpened) return; + // This is needed to force Safari toolbar to show **before** the drawer starts animating to prevent a gnarly shift from happening + if (isOpen) { + // avoid for standalone mode (PWA) + const isStandalone = window.matchMedia('(display-mode: standalone)').matches; + !isStandalone && setPositionFixed(); + + if (!modal) { + window.setTimeout(() => { + restorePositionSetting(); + }, 500); + } + } else { + restorePositionSetting(); + } + }, [isOpen, hasBeenOpened, activeUrl, modal, nested, setPositionFixed, restorePositionSetting]); + + return { restorePositionSetting }; +} diff --git a/test/src/app/with-snap-points/page.tsx b/test/src/app/with-snap-points/page.tsx index 52a9b776..2bd2e8ea 100644 --- a/test/src/app/with-snap-points/page.tsx +++ b/test/src/app/with-snap-points/page.tsx @@ -14,7 +14,7 @@ export default function Page() { return (
{activeSnapPointIndex}
- + diff --git a/test/src/app/without-scaled-background/page.tsx b/test/src/app/without-scaled-background/page.tsx index 10f16ef5..48609f16 100644 --- a/test/src/app/without-scaled-background/page.tsx +++ b/test/src/app/without-scaled-background/page.tsx @@ -5,12 +5,11 @@ import { Drawer } from 'vaul'; export default function Page() { const [open, setOpen] = useState(false); - const [parent, setParent] = useState(null); return (
-
- +
+ + - - + + +
+
+
+ Unstyled drawer for React. +

+ This component can be used as a replacement for a Dialog on mobile and tablet devices. +

+

+ It uses{' '} + + Radix's Dialog primitive + {' '} + under the hood and is inspired by{' '} + + this tweet. + +

+
+
+ +
From 138fa272c1d8e96f404def539e697e8c2b75994f Mon Sep 17 00:00:00 2001 From: Emil Kowalski Date: Sun, 22 Sep 2024 23:15:08 +0200 Subject: [PATCH 4/8] Fix inability to drag when scrolled --- src/index.tsx | 15 ++++++++------- test/src/app/globals.css | 2 +- test/src/app/without-scaled-background/page.tsx | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 9cb2a238..cf3aa24c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -190,7 +190,7 @@ export function Root({ // Ensure we maintain correct pointer capture even when going outside of the drawer (event.target as HTMLElement).setPointerCapture(event.pointerId); - pointerStart.current = isVertical(direction) ? event.clientY : event.clientX; + pointerStart.current = isVertical(direction) ? event.pageY : event.pageX; } function shouldDrag(el: EventTarget, isDraggingInDirection: boolean) { @@ -229,12 +229,12 @@ export function Root({ return false; } - if (isDraggingInDirection) { - lastTimeDragPrevented.current = date; + // if (isDraggingInDirection) { + // lastTimeDragPrevented.current = date; - // We are dragging down so we should allow scrolling - return false; - } + // // We are dragging down so we should allow scrolling + // return false; + // } // Keep climbing up the DOM tree as long as there's a parent while (element) { @@ -269,8 +269,9 @@ export function Root({ if (isDragging) { const directionMultiplier = direction === 'bottom' || direction === 'right' ? 1 : -1; const draggedDistance = - (pointerStart.current - (isVertical(direction) ? event.clientY : event.clientX)) * directionMultiplier; + (pointerStart.current - (isVertical(direction) ? event.pageY : event.pageX)) * directionMultiplier; const isDraggingInDirection = draggedDistance > 0; + console.log({ draggedDistance, pointerStart: pointerStart.current, clientY: event.clientY }); // Pre condition for disallowing dragging in the close direction. const noCloseSnapPointsPreCondition = snapPoints && !dismissible && !isDraggingInDirection; diff --git a/test/src/app/globals.css b/test/src/app/globals.css index a94fb7c6..aa832064 100644 --- a/test/src/app/globals.css +++ b/test/src/app/globals.css @@ -4,7 +4,7 @@ body, main { - /* min-height: 500vh; */ + min-height: 500vh; } html { diff --git a/test/src/app/without-scaled-background/page.tsx b/test/src/app/without-scaled-background/page.tsx index 48609f16..290e9d82 100644 --- a/test/src/app/without-scaled-background/page.tsx +++ b/test/src/app/without-scaled-background/page.tsx @@ -9,7 +9,7 @@ export default function Page() { return (
- + + - - -
-
-
- Unstyled drawer for React. -

- This component can be used as a replacement for a Dialog on mobile and tablet devices. -

-

- It uses{' '} - - Radix's Dialog primitive - {' '} - under the hood and is inspired by{' '} - - this tweet. - -

-
-
- - + +
From afdf7e927405146188d83c5a83d54b90bd2fe7fa Mon Sep 17 00:00:00 2001 From: Emil Kowalski Date: Sun, 22 Sep 2024 23:23:37 +0200 Subject: [PATCH 7/8] Update tests page --- test/src/app/with-snap-points/page.tsx | 2 +- test/src/app/without-scaled-background/page.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/src/app/with-snap-points/page.tsx b/test/src/app/with-snap-points/page.tsx index 2bd2e8ea..52a9b776 100644 --- a/test/src/app/with-snap-points/page.tsx +++ b/test/src/app/with-snap-points/page.tsx @@ -14,7 +14,7 @@ export default function Page() { return (
{activeSnapPointIndex}
- + diff --git a/test/src/app/without-scaled-background/page.tsx b/test/src/app/without-scaled-background/page.tsx index 290e9d82..10f16ef5 100644 --- a/test/src/app/without-scaled-background/page.tsx +++ b/test/src/app/without-scaled-background/page.tsx @@ -5,11 +5,12 @@ import { Drawer } from 'vaul'; export default function Page() { const [open, setOpen] = useState(false); + const [parent, setParent] = useState(null); return (
-
- +
+ + + + + +
+
+
+ Unstyled drawer for React. + + +
+
+ + + + + ); +} + +export function MyDrawer2({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) { + return ( + + + + +
+
+
+ Unstyled drawer for React. +

+ This component can be used as a replacement for a Dialog on mobile and tablet devices. +

+

+ It uses{' '} + + Radix’s Dialog primitive + {' '} + under the hood and is inspired by{' '} + + this tweet. + +

+ + +
+
+ + + + + ); +} + +export default function Home() { + const [open, setOpen] = useState(false); + const [open2, setOpen2] = useState(false); + + return ( +
+

scroll down

+ + +

scroll down

+
+ ); +}