Skip to content

Commit

Permalink
Fix controlled state (#166)
Browse files Browse the repository at this point in the history
* Improve

* Add tests

* Cleanup

* Remove only
  • Loading branch information
emilkowalski authored Nov 9, 2023
1 parent fbf0ead commit db6f58a
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 3 deletions.
4 changes: 4 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface DrawerContextValue {
visible: boolean;
closeDrawer: () => void;
setVisible: (o: boolean) => void;
openProp?: boolean;
onOpenChange?: (o: boolean) => void;
}

export const DrawerContext = React.createContext<DrawerContextValue>({
Expand All @@ -34,6 +36,7 @@ export const DrawerContext = React.createContext<DrawerContextValue>({
onNestedDrag: () => {},
onNestedOpenChange: () => {},
onNestedRelease: () => {},
openProp: undefined,
dismissible: false,
isOpen: false,
keyboardIsOpen: { current: false },
Expand All @@ -42,6 +45,7 @@ export const DrawerContext = React.createContext<DrawerContextValue>({
modal: false,
shouldFade: false,
activeSnapPoint: null,
onOpenChange: () => {},
setActiveSnapPoint: () => {},
visible: false,
closeDrawer: () => {},
Expand Down
15 changes: 12 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,11 @@ function Root({
<DialogPrimitive.Root
modal={modal}
onOpenChange={(o: boolean) => {
if (openProp !== undefined) {
onOpenChange?.(o);
return;
}

if (!o) {
closeDrawer();
} else {
Expand All @@ -631,6 +636,7 @@ function Root({
drawerRef,
overlayRef,
scaleBackground,
onOpenChange,
onPress,
setVisible,
onRelease,
Expand All @@ -643,6 +649,7 @@ function Root({
onNestedOpenChange,
onNestedRelease,
keyboardIsOpen,
openProp,
modal,
snapPointsOffset,
}}
Expand Down Expand Up @@ -694,6 +701,8 @@ const Content = React.forwardRef<HTMLDivElement, ContentProps>(function (
visible,
closeDrawer,
modal,
openProp,
onOpenChange,
setVisible,
} = useDrawerContext();
const composedRef = useComposedRefs(ref, drawerRef);
Expand All @@ -715,6 +724,7 @@ const Content = React.forwardRef<HTMLDivElement, ContentProps>(function (
}}
onPointerDown={onPress}
onPointerDownOutside={(e) => {
onPointerDownOutside?.(e);
if (!modal) {
e.preventDefault();
return;
Expand All @@ -723,13 +733,12 @@ const Content = React.forwardRef<HTMLDivElement, ContentProps>(function (
keyboardIsOpen.current = false;
}
e.preventDefault();

if (!dismissible) {
onOpenChange?.(false);
if (!dismissible || openProp !== undefined) {
return;
}

closeDrawer();
onPointerDownOutside?.(e);
}}
onPointerMove={onDrag}
onPointerUp={onRelease}
Expand Down
206 changes: 206 additions & 0 deletions test/src/app/controlled/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
'use client';

import { useState } from 'react';
import { Drawer } from 'vaul';

export default function Page() {
const [open, setOpen] = useState(false);
const [fullyControlled, setFullyControlled] = useState(false);

return (
<div className="w-screen h-screen bg-white p-8 flex justify-center items-center" vaul-drawer-wrapper="">
<Drawer.Root open={open}>
<Drawer.Trigger asChild onClick={() => setOpen(true)}>
<button data-testid="trigger" className="text-2xl">
Open Drawer
</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay data-testid="overlay" className="fixed inset-0 bg-black/40" />
<Drawer.Content
data-testid="content"
className="bg-zinc-100 flex flex-col rounded-t-[10px] h-[96%] mt-24 fixed bottom-0 left-0 right-0"
>
<Drawer.Close data-testid="drawer-close">Close</Drawer.Close>
<button data-testid="controlled-close" onClick={() => setOpen(false)} className="text-2xl">
Close
</button>
<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"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="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"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="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>
<Drawer.Root open={fullyControlled} onOpenChange={(o) => setFullyControlled(o)}>
<Drawer.Trigger asChild>
<button data-testid="fully-controlled-trigger" className="text-2xl">
Open Drawer
</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay data-testid="overlay" className="fixed inset-0 bg-black/40" />
<Drawer.Content
data-testid="fully-controlled-content"
className="bg-zinc-100 flex flex-col rounded-t-[10px] h-[96%] mt-24 fixed bottom-0 left-0 right-0"
>
<Drawer.Close data-testid="drawer-close">Close</Drawer.Close>
<button data-testid="controlled-close" onClick={() => setOpen(false)} className="text-2xl">
Close
</button>
<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"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="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"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="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>
);
}
1 change: 1 addition & 0 deletions test/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default function Page() {
<Link href="/nested-drawers">Nested drawers</Link>
<Link href="/non-dismissible">Non-dismissible</Link>
<Link href="/initial-snap">Initial snap</Link>
<Link href="/controlled">Controlled</Link>
</div>
);
}
30 changes: 30 additions & 0 deletions test/tests/controlled.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
import { ANIMATION_DURATION } from './constants';

test.beforeEach(async ({ page }) => {
await page.goto('/controlled');
});

test.describe('Initial-snap', () => {
test('should not close when clicked on overlay and only the open prop is passsed', async ({ page }) => {
await expect(page.getByTestId('content')).not.toBeVisible();
await page.getByTestId('trigger').click();
await expect(page.getByTestId('content')).toBeVisible();
// Click on the background
await page.mouse.click(0, 0);

await page.waitForTimeout(ANIMATION_DURATION);
await expect(page.getByTestId('content')).toBeVisible();
});

test('should close when clicked on overlay and open and onOpenChange props are passed', async ({ page }) => {
await expect(page.getByTestId('fully-controlled-content')).not.toBeVisible();
await page.getByTestId('fully-controlled-trigger').click();
await expect(page.getByTestId('fully-controlled-content')).toBeVisible();
// Click on the background
await page.mouse.click(0, 0);

await page.waitForTimeout(ANIMATION_DURATION);
await expect(page.getByTestId('fully-controlled-content')).not.toBeVisible();
});
});

0 comments on commit db6f58a

Please sign in to comment.