Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add usePositionFixed hook #436

Merged
merged 8 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)[];
Expand Down Expand Up @@ -58,6 +59,7 @@ export type DialogProps = {
snapToSequentialPoint?: boolean;
container?: HTMLElement | null;
onAnimationEnd?: (open: boolean) => void;
preventScrollRestoration?: boolean;
} & (WithFadeFromProps | WithoutFadeFromProps);

export function Root({
Expand All @@ -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,
Expand All @@ -94,6 +98,10 @@ export function Root({
onChange: (o: boolean) => {
onOpenChange?.(o);

if (!o) {
restorePositionSetting();
}

setTimeout(() => {
onAnimationEnd?.(o);
}, TRANSITIONS.DURATION * 1000);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -867,7 +884,7 @@ export const Handle = React.forwardRef<HTMLDivElement, HandleProps>(function (
}

const isLastSnapPoint = activeSnapPoint === snapPoints[snapPoints.length - 1];

if (isLastSnapPoint && dismissible) {
closeDrawer();
return;
Expand Down
132 changes: 132 additions & 0 deletions src/use-position-fixed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React from 'react';
import { isSafari } from './use-prevent-scroll';

let previousBodyPosition: Record<string, string> | 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 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) {
previousBodyPosition = {
position: document.body.style.position,
top: document.body.style.top,
left: document.body.style.left,
height: document.body.style.height,
right: 'unset',
};

// 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);
emilkowalski marked this conversation as resolved.
Show resolved Hide resolved

// 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 };
}
4 changes: 1 addition & 3 deletions src/use-scale-background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function useScaleBackground() {

if (!wrapper) return;

const bodyAndWrapperCleanup = chain(
chain(
setBackgroundColorOnScale && !noBodyStyles ? assignStyle(document.body, { background: 'black' }) : noop,
assignStyle(wrapper, {
transformOrigin: isVertical(direction) ? 'top' : 'left',
Expand All @@ -48,8 +48,6 @@ export function useScaleBackground() {
return () => {
wrapperStylesCleanup();
timeoutIdRef.current = window.setTimeout(() => {
bodyAndWrapperCleanup();

if (initialBackgroundColor) {
document.body.style.background = initialBackgroundColor;
} else {
Expand Down
167 changes: 84 additions & 83 deletions test/src/app/with-scaled-background/page.tsx
Original file line number Diff line number Diff line change
@@ -1,94 +1,95 @@
'use client';

import { useState } from 'react';
import clsx from 'clsx';
import { Drawer } from 'vaul';
import { DrawerDirection } from 'vaul/src/types';

const CenteredContent = () => {
export default function MyDrawer() {
Copy link
Contributor

@maiconcarraro maiconcarraro Sep 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if possible I'd say to add an example of drawer that opens after a scroll and also the drawer to open another drawer (not nested), since these 2 situations are very common to real products and these bugs were spotted only in these circumstances

Copy link
Owner Author

@emilkowalski emilkowalski Sep 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a new page (/open-another-drawer), could you verify whether it works for you now please?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

running locally it works 👍 (can't verify from iphone atm)
image

return (
<div className="max-w-md mx-auto">
<Drawer.Title className="font-medium mb-4">Unstyled drawer for React.</Drawer.Title>
<p className="text-zinc-600 mb-2">
This component can be used as a replacement for a Dialog on mobile and tablet devices.
</p>
<p className="text-zinc-600 mb-8">
It uses{' '}
<a href="https://www.radix-ui.com/docs/primitives/components/dialog" className="underline" target="_blank">
Radix&apos;s Dialog primitive
</a>{' '}
under the hood and is inspired by{' '}
<a href="https://twitter.com/devongovett/status/1674470185783402496" className="underline" target="_blank">
this tweet.
</a>
</p>
</div>
);
};

const DrawerContent = ({ drawerDirection }: { drawerDirection: DrawerDirection }) => {
return (
<Drawer.Content
data-testid="content"
className={clsx({
'bg-zinc-100 flex fixed p-6': true,
'rounded-t-[10px] flex-col h-[50%] bottom-0 left-0 right-0': drawerDirection === 'bottom',
'rounded-b-[10px] flex-col h-[50%] top-0 left-0 right-0': drawerDirection === 'top',
'rounded-r-[10px] flex-row w-[50%] left-0 top-0 bottom-0': drawerDirection === 'left',
'rounded-l-[10px] flex-row w-[50%] right-0 top-0 bottom-0': drawerDirection === 'right',
})}
>
<div
className={clsx({
'w-full h-full flex rounded-full gap-8': true,
'flex-col': drawerDirection === 'bottom',
'flex-col-reverse': drawerDirection === 'top',
'flex-row-reverse': drawerDirection === 'left',
'flex-row ': drawerDirection === 'right',
})}
>
<div
className={clsx({
'rounded-full bg-zinc-300': true,
'mx-auto w-12 h-1.5': drawerDirection === 'top' || drawerDirection === 'bottom',
'my-auto h-12 w-1.5': drawerDirection === 'left' || drawerDirection === 'right',
})}
/>
<div className="grid place-content-center w-full h-full">
<CenteredContent />
</div>
</div>
</Drawer.Content>
);
};

export default function Page() {
const [direction, setDirection] = useState<DrawerDirection>('bottom');

return (
<div
className="w-screen h-screen bg-white p-8 flex flex-col gap-2 justify-center items-center"
data-vaul-drawer-wrapper=""
>
<select
value={direction}
className="border-zinc-300 border-2 px-4 py-1 rounded-lg"
onChange={(e) => setDirection(e.target.value as DrawerDirection)}
>
<option value="top">Top</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
<Drawer.Root shouldScaleBackground direction={direction}>
<div data-vaul-drawer-wrapper="" className="h-screen bg-white">
<Drawer.Root shouldScaleBackground>
<Drawer.Trigger asChild>
<button data-testid="trigger" className="text-2xl">
Open Drawer
</button>
<button>Open Drawer</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay data-testid="overlay" className="fixed inset-0 bg-black/40" />
<DrawerContent drawerDirection={direction} />
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="bg-zinc-100 flex flex-col rounded-t-[10px] h-[96%] mt-24 fixed bottom-0 left-0 right-0">
<div className="p-4 bg-white rounded-t-[10px] flex-1">
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-zinc-300 mb-8" />
<div className="max-w-md mx-auto">
<Drawer.Title className="font-medium mb-4">Unstyled drawer for React.</Drawer.Title>
<p className="text-zinc-600 mb-2">
This component can be used as a replacement for a Dialog on mobile and tablet devices.
</p>
<p className="text-zinc-600 mb-8">
It uses{' '}
<a
href="https://www.radix-ui.com/docs/primitives/components/dialog"
className="underline"
target="_blank"
>
Radix&apos;s Dialog primitive
</a>{' '}
under the hood and is inspired by{' '}
<a
href="https://twitter.com/devongovett/status/1674470185783402496"
className="underline"
target="_blank"
>
this tweet.
</a>
</p>
</div>
</div>
<div className="p-4 bg-zinc-100 border-t border-zinc-200 mt-auto">
<div className="flex gap-6 justify-end max-w-md mx-auto">
<a
className="text-xs text-zinc-600 flex items-center gap-0.25"
href="https://github.com/emilkowalski/vaul"
target="_blank"
>
GitHub
<svg
fill="none"
height="16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="16"
aria-hidden="true"
className="w-3 h-3 ml-1"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path>
<path d="M15 3h6v6"></path>
<path d="M10 14L21 3"></path>
</svg>
</a>
<a
className="text-xs text-zinc-600 flex items-center gap-0.25"
href="https://twitter.com/emilkowalski_"
target="_blank"
>
Twitter
<svg
fill="none"
height="16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="16"
aria-hidden="true"
className="w-3 h-3 ml-1"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path>
<path d="M15 3h6v6"></path>
<path d="M10 14L21 3"></path>
</svg>
</a>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</div>
Expand Down
Loading
Loading