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

feat: drawer handle component #182

Merged
merged 18 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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 @@ -849,6 +962,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 @@ -871,6 +985,7 @@ const Content = React.forwardRef<HTMLDivElement, ContentProps>(function (
closeDrawer();
}}
onPointerMove={(event) => {
if (handleOnly) return;
rest.onPointerMove?.(event);
if (!pointerStartRef.current) return null;
const yPosition = event.clientY - pointerStartRef.current.y;
Expand Down Expand Up @@ -934,6 +1049,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]);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is needed in order to setActiveSnapPoint updates to sync


function onRelease({
draggedDistance,
Expand Down
Loading
Loading