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: Add support for new <Drawer.SnapPoint /> #548

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
70 changes: 62 additions & 8 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ export interface WithFadeFromProps {
* 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.
* Should <Drawer.SnapPoint /> be used, calculated points will be included in the callback.
*/
snapPoints: (number | string)[];
snapPoints: (number | string)[] | ((embeddedSnapPoints: (number | string)[]) => (number | string)[]);
/**
* Index of a `snapPoint` from which the overlay fade should be applied. Defaults to the last snap point.
*/
Expand All @@ -42,8 +43,9 @@ export interface WithoutFadeFromProps {
* 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.
* Should <Drawer.SnapPoint /> be used, calculated points will be included in the callback.
*/
snapPoints?: (number | string)[];
snapPoints?: (number | string)[] | ((embeddedSnapPoints: (number | string)[]) => (number | string)[]);
fadeFromIndex?: never;
}

Expand Down Expand Up @@ -142,14 +144,14 @@ export function Root({
children,
onDrag: onDragProp,
onRelease: onReleaseProp,
snapPoints,
snapPoints: initialSnapPoints,
shouldScaleBackground = false,
setBackgroundColorOnScale = true,
closeThreshold = CLOSE_THRESHOLD,
scrollLockTimeout = SCROLL_LOCK_TIMEOUT,
dismissible = true,
handleOnly = false,
fadeFromIndex = snapPoints && snapPoints.length - 1,
fadeFromIndex: initialFadeFromIndex,
activeSnapPoint: activeSnapPointProp,
setActiveSnapPoint: setActiveSnapPointProp,
fixed,
Expand All @@ -167,6 +169,12 @@ export function Root({
container,
autoFocus = false,
}: DialogProps) {
const [snapPoints, setSnapPoints] = React.useState<(string | number)[] | undefined>(
// If no snap points are provided, default to [0, 1] until we can ensure no snap points are present in DOM
(typeof initialSnapPoints === 'function' ? initialSnapPoints([]) : initialSnapPoints) ?? [0, 1],
);
const fadeFromIndex = initialFadeFromIndex ?? (snapPoints && snapPoints.length - 1);

const [isOpen = false, setIsOpen] = useControllableState({
defaultProp: defaultOpen,
prop: openProp,
Expand Down Expand Up @@ -214,10 +222,13 @@ export function Root({
const drawerWidthRef = React.useRef(drawerRef.current?.getBoundingClientRect().width || 0);
const initialDrawerHeight = React.useRef(0);

const onSnapPointChange = React.useCallback((activeSnapPointIndex: number) => {
// Change openTime ref when we reach the last snap point to prevent dragging for 500ms incase it's scrollable.
if (snapPoints && activeSnapPointIndex === snapPointsOffset.length - 1) openTime.current = new Date();
}, []);
const onSnapPointChange = React.useCallback(
(activeSnapPointIndex: number) => {
// Change openTime ref when we reach the last snap point to prevent dragging for 500ms incase it's scrollable.
if (snapPoints && activeSnapPointIndex === snapPointsOffset.length - 1) openTime.current = new Date();
},
[snapPoints],
);

const {
activeSnapPoint,
Expand Down Expand Up @@ -477,6 +488,8 @@ export function Root({
function onVisualViewportChange() {
if (!drawerRef.current || !repositionInputs) return;

updateSnapPoints();

const focusedElement = document.activeElement as HTMLElement;
if (isInput(focusedElement) || keyboardIsOpen.current) {
const visualViewportHeight = window.visualViewport?.height || 0;
Expand Down Expand Up @@ -662,6 +675,36 @@ export function Root({
resetDrawer();
}

function updateSnapPoints() {
if (!drawerRef.current || (initialSnapPoints && typeof initialSnapPoints !== 'function')) return;
const drawerPosition = drawerRef.current?.getBoundingClientRect().y ?? 0;
const snapPointsNodes = document.querySelectorAll('[data-vaul-snap-point]');
if (snapPointsNodes.length === 0) return;
const embeddedSnapPoints = Array.from(snapPointsNodes).map((snapPoint) => {
const snapPointOffset = Number(snapPoint.getAttribute('data-vaul-offset')) ?? 0;
const snapPointVerticalPosition = snapPoint.getBoundingClientRect().y;
return `${snapPointVerticalPosition + WINDOW_TOP_OFFSET + snapPointOffset - drawerPosition}px`;
});
const newSnapPoints = initialSnapPoints?.(embeddedSnapPoints) ?? embeddedSnapPoints;
setSnapPoints(newSnapPoints);

return newSnapPoints;
}

React.useEffect(() => {
if (isOpen) {
// Detect if any snapPoints are present in DOM, otherwise set snapPoints to undefined
window.requestAnimationFrame(() => {
const newSnapPoints = updateSnapPoints();
if (newSnapPoints) {
setActiveSnapPoint(newSnapPoints[0]);
} else if (!initialSnapPoints) {
setSnapPoints(undefined);
}
});
}
}, [isOpen]);

React.useEffect(() => {
// Trigger enter animation without using CSS animation
if (isOpen) {
Expand Down Expand Up @@ -1095,6 +1138,16 @@ export const Handle = React.forwardRef<HTMLDivElement, HandleProps>(function (

Handle.displayName = 'Drawer.Handle';

export type SnapPointProps = {
offset?: number;
};

export const SnapPoint = React.forwardRef<HTMLAnchorElement, SnapPointProps>(function ({ offset = 0 }, ref) {
return <a ref={ref} data-vaul-snap-point data-vaul-offset={offset} />;
});

SnapPoint.displayName = 'Drawer.SnapPoint';

export function NestedRoot({ onDrag, onOpenChange, open: nestedIsOpen, ...rest }: DialogProps) {
const { onNestedDrag, onNestedOpenChange, onNestedRelease } = useDrawerContext();

Expand Down Expand Up @@ -1145,4 +1198,5 @@ export const Drawer = {
Close: DialogPrimitive.Close,
Title: DialogPrimitive.Title,
Description: DialogPrimitive.Description,
SnapPoint,
};
1 change: 1 addition & 0 deletions test/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default function Page() {
<Link href="/with-scaled-background">With scaled background</Link>
<Link href="/without-scaled-background">Without scaled background</Link>
<Link href="/with-snap-points">With snap points</Link>
<Link href="/with-embedded-snap-points">With embedded snap points</Link>
<Link href="/with-modal-false">With modal false</Link>
<Link href="/scrollable-with-inputs">Scrollable with inputs</Link>
<Link href="/nested-drawers">Nested drawers</Link>
Expand Down
152 changes: 152 additions & 0 deletions test/src/app/with-embedded-snap-points/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
'use client';

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

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

return (
<div className="w-screen h-screen bg-white p-8 flex justify-center items-center">
<Drawer.Root snapPoints={(snapPoints) => [...snapPoints, 1]} 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]"
>
<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>
<Drawer.SnapPoint offset={0} />
<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>
<Drawer.SnapPoint offset={24} />
<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>
);
}