Skip to content

Commit

Permalink
feat: drawer handle
Browse files Browse the repository at this point in the history
  • Loading branch information
rortan134 authored Dec 10, 2023
1 parent 349f66e commit ca334de
Show file tree
Hide file tree
Showing 8 changed files with 260 additions and 6 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function MyComponent() {
<Drawer.Trigger>Open</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Content>
<Drawer.Handle />
<p>Content</p>
</Drawer.Content>
<Drawer.Overlay />
Expand Down Expand Up @@ -52,13 +53,13 @@ Additional props:

`closeThreshold`: Number between 0 and 1 that determines when the drawer should be closed. Example: threshold of 0.5 would close the drawer if the user swiped for 50% of the height of the drawer or more.

`scrollLockTimeout`: Duration for which the drawer is not draggable after scrolling content inside of the drawer. Defaults to 500ms
`scrollLockTimeout`: Duration for which the drawer is not draggable after scrolling content inside of the drawer. Defaults to 500ms.

`snapPoints`: Array of numbers from 0 to 100 that corresponds to % of the screen a given snap point should take up. Should go from least visible. Example `[0.2, 0.5, 0.8]`. You can also use px values, which doesn't take screen height into account.

`fadeFromIndex`: Index of a `snapPoint` from which the overlay fade should be applied. Defaults to the last snap point.

`modal`: When `false`it allows to interact with elements outside of the drawer without closing it. Defaults to`true`.
`modal`: When `false` it allows to interact with elements outside of the drawer without closing it. Defaults to `true`.

### Trigger

Expand All @@ -84,6 +85,10 @@ An optional accessible description to be announced when the dialog is opened. [P

The button that closes the dialog. [Props](https://www.radix-ui.com/docs/primitives/components/dialog#close).

### Handle

A drag hint (also known as grabber). Shows people that they can drag the drawer to resize it; they can also tap it to cycle through the snap points, and double tap quickly to close the drawer. Set `preventCycle={false}` to stop this default behavior.

### Portal

Portals your drawer into the body.
2 changes: 2 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface DrawerContextValue {
onNestedRelease: (event: React.PointerEvent<HTMLDivElement>, open: boolean) => void;
dismissible: boolean;
isOpen: boolean;
isDragging: false;
keyboardIsOpen: React.MutableRefObject<boolean>;
snapPointsOffset: number[] | null;
snapPoints?: (number | string)[] | null;
Expand Down Expand Up @@ -39,6 +40,7 @@ export const DrawerContext = React.createContext<DrawerContextValue>({
openProp: undefined,
dismissible: false,
isOpen: false,
isDragging: false,
keyboardIsOpen: { current: false },
snapPointsOffset: null,
snapPoints: null,
Expand Down
38 changes: 38 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@ function Root({
onDrag,
dismissible,
isOpen,
isDragging,
shouldFade,
closeDrawer,
onNestedDrag,
Expand All @@ -672,6 +673,42 @@ function Root({
);
}

type HandleProps = React.HTMLAttributes<HTMLSpanElement> & {
preventCycle?: boolean;
};

const Handle = React.forwardRef<HTMLSpanElement, HandleProps>(({ preventCycle = false, ...rest }, ref) => {
const { visible, closeDrawer, isDragging, snapPoints, activeSnapPoint, setActiveSnapPoint, dismissible } =
useDrawerContext();

function handleCycleSnapPoints() {
// Prevent accidental taps while resizing drawer
if (isDragging || preventCycle) return;

const isLastSnapPoint = activeSnapPoint === snapPoints?.[snapPoints?.length - 1] ?? null;
if (!snapPoints || (isLastSnapPoint && dismissible)) {
closeDrawer();
return;
}

const nextSnapPoint = snapPoints[snapPoints.findIndex((point) => point === activeSnapPoint) + 1];
setActiveSnapPoint(nextSnapPoint);
}

return (
<span
onClick={handleCycleSnapPoints}
onDoubleClick={closeDrawer}
ref={ref}
vaul-drawer-visible={visible ? 'true' : 'false'}
vaul-handle=""
aria-hidden="true"
{...rest}
/>
);
});
Handle.displayName = 'Drawer.Handle';

const Overlay = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>(
function ({ children, ...rest }, ref) {
const { overlayRef, snapPoints, onRelease, shouldFade, isOpen, visible } = useDrawerContext();
Expand Down Expand Up @@ -805,6 +842,7 @@ export const Drawer = {
Root,
NestedRoot,
Content,
Handle,
Overlay,
Trigger: DialogPrimitive.Trigger,
Portal: DialogPrimitive.Portal,
Expand Down
22 changes: 22 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@
height: 200%;
}

[vaul-handle] {
display: block;
opacity: 0.8;
margin-top: 4px;
margin-left: auto;
margin-right: auto;
height: 5px;
width: 56px;
border-radius: 1rem;
touch-action: pan-y;
cursor: grab;
}

[vaul-handle]:hover,
[vaul-handle]:active {
opacity: 1;
}

[vaul-handle]:active {
cursor: grabbing;
}

[vaul-overlay][vaul-snap-points='true']:not([vaul-snap-points-overlay='true']):not([data-state='closed']) {
opacity: 0;
}
Expand Down
8 changes: 5 additions & 3 deletions src/use-snap-points.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,15 @@ export function useSnapPoints({
);

React.useEffect(() => {
if (activeSnapPointProp) {
const newIndex = snapPoints?.findIndex((snapPoint) => snapPoint === activeSnapPointProp) ?? null;
if (activeSnapPoint || activeSnapPointProp) {
const newIndex =
snapPoints?.findIndex((snapPoint) => snapPoint === activeSnapPointProp || snapPoint === activeSnapPoint) ??
null;
if (snapPointsOffset && newIndex && typeof snapPointsOffset[newIndex] === 'number') {
snapToPoint(snapPointsOffset[newIndex] as number);
}
}
}, [activeSnapPointProp, snapPoints, snapPointsOffset, snapToPoint]);
}, [activeSnapPoint, activeSnapPointProp, snapPoints, snapPointsOffset, snapToPoint]);

function onRelease({
draggedDistance,
Expand Down
156 changes: 156 additions & 0 deletions test/src/app/with-handle/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
'use client';

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

const snapPoints = [0, '148px', '355px'];

export default function Page() {
const [snap, setSnap] = useState<number | string | null>(snapPoints[1]);

const activeSnapPointIndex = snapPoints.indexOf(snap as string);

return (
<div className="w-screen h-screen bg-white p-8 flex justify-center items-center">
<div data-testid="active-snap-index">{activeSnapPointIndex}</div>
<Drawer.Root open snapPoints={snapPoints} activeSnapPoint={snap} setActiveSnapPoint={setSnap}>
<Drawer.Trigger asChild>
<button data-testid="trigger">Open Drawer</button>
</Drawer.Trigger>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Portal>
<Drawer.Content
data-testid="content"
className="fixed flex flex-col bg-white border border-gray-200 border-b-none rounded-t-[10px] bottom-0 left-0 right-0 h-full max-h-[97%] mx-[-1px]"
>
<Drawer.Handle data-testid="handle" className="bg-gray-300 mb-8" />
<div
className={clsx('flex flex-col max-w-md mx-auto w-full p-4 pt-5', {
'overflow-y-auto': snap === 1,
'overflow-hidden': snap !== 1,
})}
>
<div className="flex items-center">
<svg
className="text-yellow-400 h-5 w-5 flex-shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z"
clip-rule="evenodd"
></path>
</svg>
<svg
className="text-yellow-400 h-5 w-5 flex-shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z"
clip-rule="evenodd"
></path>
</svg>
<svg
className="text-yellow-400 h-5 w-5 flex-shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z"
clip-rule="evenodd"
></path>
</svg>
<svg
className="text-yellow-400 h-5 w-5 flex-shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z"
clip-rule="evenodd"
></path>
</svg>
<svg
className="text-gray-300 h-5 w-5 flex-shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401z"
clip-rule="evenodd"
></path>
</svg>
</div>{' '}
<h1 className="text-2xl mt-2 font-medium">The Hidden Details</h1>
<p className="text-sm mt-1 text-gray-600 mb-6">2 modules, 27 hours of video</p>
<p className="text-gray-600">
The world of user interface design is an intricate landscape filled with hidden details and nuance. In
this course, you will learn something cool. To the untrained eye, a beautifully designed UI.
</p>
<button className="bg-black text-gray-50 mt-8 rounded-md h-[48px] flex-shrink-0 font-medium">
Buy for $199
</button>
<div className="mt-12">
<h2 className="text-xl font-medium">Module 01. The Details</h2>
<div className="space-y-4 mt-4">
<div>
<span className="block">Layers of UI</span>
<span className="text-gray-600">A basic introduction to Layers of Design.</span>
</div>
<div>
<span className="block">Typography</span>
<span className="text-gray-600">The fundamentals of type.</span>
</div>
<div>
<span className="block">UI Animations</span>
<span className="text-gray-600">Going through the right easings and durations.</span>
</div>
</div>
</div>
<div className="mt-12">
<figure>
<blockquote className="font-serif">
“I especially loved the hidden details video. That was so useful, learned a lot by just reading it.
Can&rsquo;t wait for more course content!”
</blockquote>
<figcaption>
<span className="text-sm text-gray-600 mt-2 block">Yvonne Ray, Frontend Developer</span>
</figcaption>
</figure>
</div>
<div className="mt-12">
<h2 className="text-xl font-medium">Module 02. The Process</h2>
<div className="space-y-4 mt-4">
<div>
<span className="block">Build</span>
<span className="text-gray-600">Create cool components to practice.</span>
</div>
<div>
<span className="block">User Insight</span>
<span className="text-gray-600">Find out what users think and fine-tune.</span>
</div>
<div>
<span className="block">Putting it all together</span>
<span className="text-gray-600">Let&apos;s build an app together and apply everything.</span>
</div>
</div>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</div>
);
}
29 changes: 29 additions & 0 deletions test/tests/with-handle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
import { ANIMATION_DURATION } from './constants';

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

test.describe('With handle', () => {
test('click should cycle the snap point', async ({ page }) => {
await page.waitForTimeout(ANIMATION_DURATION);

await expect(page.getByTestId('content')).toBeVisible();
await expect(page.getByTestId('active-snap-index')).toHaveText('1');

await page.getByTestId('handle').click();

await expect(page.getByTestId('active-snap-index')).toHaveText('2');

await page.getByTestId('handle').click();
await expect(page.getByTestId('content')).not.toBeVisible();
});

test('Double click should close the drawer', async ({ page }) => {
await expect(page.getByTestId('content')).toBeVisible();
await page.getByTestId('handle').dblclick();

await expect(page.getByTestId('content')).not.toBeVisible();
});
});
2 changes: 1 addition & 1 deletion website/src/app/components/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function Hero() {
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="bg-gray-100 flex flex-col rounded-t-[10px] h-full mt-24 max-h-[96%] 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-gray-300 mb-8" />
<Drawer.Handle className="bg-gray-300 mb-8" />
<div className="max-w-md mx-auto">
<Drawer.Title className="font-medium mb-4">Drawer for React.</Drawer.Title>
<p className="text-gray-600 mb-2">
Expand Down

0 comments on commit ca334de

Please sign in to comment.