Skip to content

Commit

Permalink
feat: drawer handle component (#182)
Browse files Browse the repository at this point in the history
* feat: drawer handle

* fix: readme

* fix type

* fix test

* feat: expand handle hit area & cancel on long press

* extract magic number

* fix: reversed styles

* feat: handle drag

* remove margin

* fix tests

* fix conflicts

* revert

This reverts commit 6692eeb.

* improve undefined checks

* add small click delay to more reliably detect double taps
  • Loading branch information
rortan134 authored May 7, 2024
1 parent cefaedc commit e2cfd4d
Show file tree
Hide file tree
Showing 8 changed files with 368 additions and 6 deletions.
11 changes: 9 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 @@ -54,13 +55,15 @@ 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`.

`handleOnly`: When `true` only allows the drawer to be dragged by the `<Drawer.Handle />` component. Defaults to `false`.

`direction`: Direction of the drawer. Can be `top` or `bottom`, `left`, `right`. Defaults to `bottom`.

Expand Down Expand Up @@ -94,6 +97,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={true}` to stop this behavior. If you want to change the handle's hit area you can do so by styling the `[vaul-handle-hitarea]` selector (Defaults to 44x44 on mobile devices).

### Portal

Portals your drawer into the body.
4 changes: 4 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ interface DrawerContextValue {
onNestedOpenChange: (o: boolean) => void;
onNestedRelease: (event: React.PointerEvent<HTMLDivElement>, open: boolean) => void;
dismissible: boolean;
handleOnly: boolean;
isOpen: boolean;
isDragging: boolean;
keyboardIsOpen: React.MutableRefObject<boolean>;
snapPointsOffset: number[] | null;
snapPoints?: (number | string)[] | null;
Expand Down Expand Up @@ -40,7 +42,9 @@ export const DrawerContext = React.createContext<DrawerContextValue>({
onNestedRelease: () => {},
openProp: undefined,
dismissible: false,
handleOnly: false,
isOpen: false,
isDragging: false,
keyboardIsOpen: { current: false },
snapPointsOffset: null,
snapPoints: null,
Expand Down
116 changes: 116 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type DialogProps = {
scrollLockTimeout?: number;
fixed?: boolean;
dismissible?: boolean;
handleOnly?: boolean;
onDrag?: (event: React.PointerEvent<HTMLDivElement>, percentageDragged: number) => void;
onRelease?: (event: React.PointerEvent<HTMLDivElement>, open: boolean) => void;
modal?: boolean;
Expand All @@ -67,6 +68,7 @@ function Root({
closeThreshold = CLOSE_THRESHOLD,
scrollLockTimeout = SCROLL_LOCK_TIMEOUT,
dismissible = true,
handleOnly = false,
fadeFromIndex = snapPoints && snapPoints.length - 1,
activeSnapPoint: activeSnapPointProp,
setActiveSnapPoint: setActiveSnapPointProp,
Expand Down Expand Up @@ -741,7 +743,9 @@ function Root({
onRelease,
onDrag,
dismissible,
handleOnly,
isOpen,
isDragging,
shouldFade,
closeDrawer,
onNestedDrag,
Expand All @@ -760,6 +764,114 @@ function Root({
);
}

type HandleProps = React.ComponentPropsWithoutRef<'div'> & {
preventCycle?: boolean;
};

const LONG_HANDLE_PRESS_TIMEOUT = 250;
const DOUBLE_TAP_TIMEOUT = 120;

const Handle = React.forwardRef<HTMLDivElement, HandleProps>(function (
{ preventCycle = false, children, ...rest },
ref,
) {
const {
visible,
closeDrawer,
isDragging,
snapPoints,
activeSnapPoint,
setActiveSnapPoint,
dismissible,
handleOnly,
onPress,
onDrag,
} = useDrawerContext();

const closeTimeoutIdRef = React.useRef<number | null>(null);
const shouldCancelInteractionRef = React.useRef(false);

function handleStartCycle() {
// Stop if this is the second click of a double click
if (shouldCancelInteractionRef.current) {
handleCancelInteraction();
return;
}
window.setTimeout(() => {
handleCycleSnapPoints();
}, DOUBLE_TAP_TIMEOUT);
}

function handleCycleSnapPoints() {
// Prevent accidental taps while resizing drawer
if (isDragging || preventCycle || shouldCancelInteractionRef.current) {
handleCancelInteraction();
return;
}
// Make sure to clear the timeout id if the user releases the handle before the cancel timeout
handleCancelInteraction();

if ((!snapPoints || snapPoints.length === 0) && dismissible) {
closeDrawer();
return;
}

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

const currentSnapIndex = snapPoints.findIndex((point) => point === activeSnapPoint);
if (currentSnapIndex === -1) return; // activeSnapPoint not found in snapPoints
const nextSnapPoint = snapPoints[currentSnapIndex + 1];
setActiveSnapPoint(nextSnapPoint);
}

function handleStartInteraction() {
closeTimeoutIdRef.current = window.setTimeout(() => {
// Cancel click interaction on a long press
shouldCancelInteractionRef.current = true;
}, LONG_HANDLE_PRESS_TIMEOUT);
}

function handleCancelInteraction() {
window.clearTimeout(closeTimeoutIdRef.current);
shouldCancelInteractionRef.current = false;
}

return (
<div
onClick={handleStartCycle}
onDoubleClick={() => {
shouldCancelInteractionRef.current = true;
closeDrawer();
}}
onPointerCancel={handleCancelInteraction}
onPointerDown={(e) => {
if (handleOnly) onPress(e);
handleStartInteraction();
}}
onPointerMove={(e) => {
if (handleOnly) onDrag(e);
}}
// onPointerUp is already handled by the content component
ref={ref}
vaul-drawer-visible={visible ? 'true' : 'false'}
vaul-handle=""
aria-hidden="true"
{...rest}
>
{/* Expand handle's hit area beyond what's visible to ensure a 44x44 tap target for touch devices */}
<span vaul-handle-hitarea="" aria-hidden="true">
{children}
</span>
</div>
);
});

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 @@ -804,6 +916,7 @@ const Content = React.forwardRef<HTMLDivElement, ContentProps>(function (
openProp,
onOpenChange,
setVisible,
handleOnly,
direction,
} = useDrawerContext();
const composedRef = useComposedRefs(ref, drawerRef);
Expand Down Expand Up @@ -863,6 +976,7 @@ const Content = React.forwardRef<HTMLDivElement, ContentProps>(function (
}
}}
onPointerDown={(event) => {
if (handleOnly) return;
rest.onPointerDown?.(event);
pointerStartRef.current = { x: event.clientX, y: event.clientY };
onPress(event);
Expand All @@ -885,6 +999,7 @@ const Content = React.forwardRef<HTMLDivElement, ContentProps>(function (
closeDrawer();
}}
onPointerMove={(event) => {
if (handleOnly) return;
rest.onPointerMove?.(event);
if (!pointerStartRef.current) return;
const yPosition = event.clientY - pointerStartRef.current.y;
Expand Down Expand Up @@ -944,6 +1059,7 @@ export const Drawer = {
Root,
NestedRoot,
Content,
Handle,
Overlay,
Trigger: DialogPrimitive.Trigger,
Portal: DialogPrimitive.Portal,
Expand Down
45 changes: 45 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,38 @@
width: 200%;
}

[vaul-handle] {
display: block;
position: relative;
opacity: 0.8;
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-handle-hitarea] {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: max(100%, 2.75rem); /* 44px */
height: max(100%, 2.75rem); /* 44px */
touch-action: inherit;
}

[vaul-overlay][vaul-snap-points='true']:not([vaul-snap-points-overlay='true']):not([data-state='closed']) {
opacity: 0;
}
Expand All @@ -113,3 +145,16 @@
to {
}
}

@media (hover: hover) and (pointer: fine) {
[vaul-drawer] {
user-select: none;
}
}

@media (pointer: fine) {
[vaul-handle-hitarea]: {
width: 100%;
height: 100%;
}
}
8 changes: 5 additions & 3 deletions src/use-snap-points.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,15 @@ export function useSnapPoints({
);

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

function onRelease({
draggedDistance,
Expand Down
Loading

0 comments on commit e2cfd4d

Please sign in to comment.