diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..ca40df5 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,155 @@ +# Migration: v1 → v2 + +`@present-day/drawer` v2 collapses the `sizing` prop and `DRAWER_SIZING` +preset object into a single `snapPoints` array, renames the change callback +to match Vaul / shadcn drawer, and reuses `SNAP_POINT.FULL` for the `'full'` +token. The migration is mechanical and codemod-friendly: every change is a +1:1 rename or a small literal substitution. + +## TL;DR find/replace + +| Before | After | +| ----------------------------------------------- | ---------------------------------- | +| `sizing="auto"` / `sizing={DRAWER_SIZING.AUTO}` | omit (default) or `snapPoints={['auto']}` | +| `sizing="full"` / `sizing={DRAWER_SIZING.FULL}` | `snapPoints={['full']}` | +| `sizing={[…]}` | `snapPoints={[…]}` | +| `onSnapPointChange={fn}` | `setActiveSnapPoint={fn}` | +| `import { DRAWER_SIZING } from '@present-day/drawer'` | _delete_ (no replacement) | +| `SnapPointValue` (type) | `SnapPoint` | +| `DrawerSizing` (type) | `SnapPoint[]` (or just remove the annotation) | +| `DrawerSizingPreset` (type) | _delete_ (no replacement) | +| `SNAP_POINT.FULL` (value `0.9`) | `SNAP_POINT.NEAR_FULL` (same `0.9`) **or** `SNAP_POINT.MAX` (`1`) | + +The new `SNAP_POINT.FULL` is the string token `'full'` (full available drawer +height). If you previously relied on `SNAP_POINT.FULL === 0.9`, you almost +certainly wanted `SNAP_POINT.MAX` — the old name was an artifact of an early +implementation that capped at 90%. + +## Why these changes + +**`sizing` and `snapPoints` were doing the same job.** The v1 `sizing` prop +accepted either a preset string (`'auto'` | `'full'`) or an array of snap +stops. But `['auto']` already expressed "content-sized" and `['full']` +already expressed "full-height" — the preset variant was sugar for what the +array form could already do, and we paid for it with two parallel constants +(`DRAWER_SIZING` and `SNAP_POINT`) that overlapped on `'auto'`. v2 keeps +only the array form. + +**`onSnapPointChange` is now `setActiveSnapPoint`** to match the Vaul / +shadcn drawer convention. The signature is a strict superset of theirs — +we still pass the resolved `index` as a second argument, so existing +handlers continue to work after the rename. + +**`SnapPoint` (renamed from `SnapPointValue`)** is the public type for any +single snap stop: `number | 'auto' | 'full'`. Use it in your own helpers and +state when the value can be any of those. + +## Step-by-step + +### 1. Rename the prop + +```tsx +// before + + + + + +// after + + + + +``` + +`'auto'` is now the default `snapPoints` value, so most call sites can drop +the prop entirely. + +### 2. Rename the callback + +```tsx +// before + …} /> + +// after + …} /> +``` + +The signature is unchanged. + +### 3. Update imports and types + +```ts +// before +import { + DRAWER_SIZING, + type DrawerSizing, + type DrawerSizingPreset, + type SnapPointValue, +} from '@present-day/drawer' + +const stops: DrawerSizing = ['auto', 480, 'full'] +const [active, setActive] = useState(0.5) + +// after +import { type SnapPoint } from '@present-day/drawer' + +const stops: SnapPoint[] = ['auto', 480, 'full'] +const [active, setActive] = useState(0.5) +``` + +### 4. Update `SNAP_POINT.FULL` users + +`SNAP_POINT.FULL` used to equal `0.9`. In v2 it equals `'full'`. + +```ts +// before + // resolved to 0.9 of available + +// after — pick whichever you actually wanted: + // same 0.9 behavior + // true full height + // also true full height (token) +``` + +`SNAP_POINT.MAX` (`1`) and `SNAP_POINT.FULL` (`'full'`) both resolve to the +full available drawer height. The token form composes more cleanly when +mixing with `'auto'`, since the resolver tags the resulting stop as `'full'` +in callbacks rather than `1`. + +## Behavior notes + +- **Default sizing.** `` with no `snapPoints` is now equivalent to + the v1 `sizing={DRAWER_SIZING.AUTO}` — content-fit via `ResizeObserver`. + No code change needed; this is a good time to remove redundant + `sizing="auto"` props. +- **Single-stop arrays.** `snapPoints={['full']}` reports `'full'` (the + string token) as the active raw value in `setActiveSnapPoint` and + `getActiveSnapPoint()`. Under v1, `sizing={DRAWER_SIZING.FULL}` reported + `1`. If you have code branching on the active raw value, accept both + `'full'` and `1` during the upgrade. +- **`resolveSizingToHeights` (advanced API).** The internal helper exported + from `useDrawerSnap` was renamed to `resolveSnapPointsToHeights` and now + accepts only `SnapPoint[]`. Pass `['auto']` instead of the v1 + `DRAWER_SIZING.AUTO`, and `['full']` instead of `DRAWER_SIZING.FULL`. + +## Vaul / shadcn drawer parity + +After v2 the prop names line up with Vaul: + +| Vaul | This package | +| ------------------------- | --------------------------------- | +| `snapPoints` | `snapPoints` ✓ | +| `activeSnapPoint` | `activeSnapPoint` ✓ | +| `setActiveSnapPoint` | `setActiveSnapPoint` (richer signature: also passes `index`) | +| `dismissible` | `dismissible` ✓ | +| `modal` | `modal` ✓ | +| `open` / `onOpenChange` | `open` / `onOpenChange` ✓ | +| _(no equivalent)_ | `defaultSnapPoint` | +| _(no equivalent)_ | `'auto'` token (content-fit) | +| _(no equivalent)_ | `topInsetPx` | + +If you’re migrating away from Vaul, the hot path is renaming +`` to `` and removing Vaul’s compound parts in favor +of this package’s `Drawer.Content`, `Drawer.Handle`, etc. The snap-point +props and the controlled-state setter behave the same. diff --git a/README.md b/README.md index a4d767e..93097d8 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ function AdvancedExample() { @@ -159,22 +159,24 @@ When the drawer is **open**, it listens to `window.visualViewport` (`resize` and ### Drawer Props -| Prop | Type | Default | Description | -| ------------------ | ------------------------- | -------- | --------------------------------- | -| `open` | `boolean` | - | Controls the open state | -| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes | -| `sizing` | `DrawerSizing` | `'auto'` | Snap point configuration | -| `defaultSnapPoint` | `number` | - | Initial snap point | -| `dismissible` | `boolean` | `true` | Allow dismissing by dragging down; Escape closes when true | -| `modal` | `boolean` | `true` | Show overlay and lock body scroll | -| `focusTrap` | `boolean` | `true` | When `modal`, trap keyboard focus in the panel; set `false` to opt out | -| `title` | `ReactNode` | - | Screen-reader title (`aria-labelledby`); use for accessible name if you have no visible title | -| `description` | `ReactNode` | - | Optional; `aria-describedby` | -| `ariaLabel` | `string` | - | Alternative accessible name if `title` is omitted | -| `overlayClassName` | `string` | - | Merged with default modal overlay | -| `slots` | `DrawerSlots` | - | Optional class names for content / handle | -| `topInsetPx` | `number` | map inset | Pixels subtracted from visual height for snap math | -| `onViewportChange` | `(viewport: ViewportInfo) => void` | - | Fired when the visual viewport updates while open; includes `layoutBottomInset`, `height`, `offsetTop`, `keyboardHeight`, `isKeyboardOpen` | +| Prop | Type | Default | Description | +| --------------------- | ------------------------------------------------- | ---------- | --------------------------------- | +| `open` | `boolean` | - | Controls the open state | +| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes | +| `snapPoints` | `SnapPoint[]` | `['auto']` | Snap stops; each entry is a fraction (`≤ 1`), pixel value (`> 1`), `'auto'`, or `'full'` | +| `defaultSnapPoint` | `SnapPoint` | last stop | Initial snap point | +| `activeSnapPoint` | `SnapPoint` | - | Controlled active snap | +| `setActiveSnapPoint` | `(point: SnapPoint, index: number) => void` | - | Called when the active snap changes (drag, ref controls, programmatic). Name matches Vaul / shadcn drawer; the `index` is provided as a convenience | +| `dismissible` | `boolean` | `true` | Allow dismissing by dragging down; Escape closes when true | +| `modal` | `boolean` | `true` | Show overlay and lock body scroll | +| `focusTrap` | `boolean` | `true` | When `modal`, trap keyboard focus in the panel; set `false` to opt out | +| `title` | `ReactNode` | - | Screen-reader title (`aria-labelledby`); use for accessible name if you have no visible title | +| `description` | `ReactNode` | - | Optional; `aria-describedby` | +| `ariaLabel` | `string` | - | Alternative accessible name if `title` is omitted | +| `overlayClassName` | `string` | - | Merged with default modal overlay | +| `slots` | `DrawerSlots` | - | Optional class names for content / handle | +| `topInsetPx` | `number` | map inset | Pixels subtracted from visual height for snap math | +| `onViewportChange` | `(viewport: ViewportInfo) => void` | - | Fired when the visual viewport updates while open; includes `layoutBottomInset`, `height`, `offsetTop`, `keyboardHeight`, `isKeyboardOpen` | ### Components @@ -185,12 +187,28 @@ When the drawer is **open**, it listens to `window.visualViewport` (`resize` and ## Migration -Breaking changes: the `BottomSheet` compatibility layer and `BOTTOM_SHEET_*` names are removed. Use the `Drawer` API everywhere, and update any custom CSS or `data-*` hooks that targeted the old names. +### v1 → v2 + +The `sizing` prop and `DRAWER_SIZING` constant are removed in favor of a single, array-shaped `snapPoints` prop that aligns with [Vaul](https://vaul.emilkowal.ski) and [shadcn drawer](https://ui.shadcn.com/docs/components/drawer). See [`MIGRATION.md`](./MIGRATION.md) for a full diff and codemod-friendly find/replace list. + +Quick summary: + +- `sizing="auto"` → omit (now the default) or pass `snapPoints={['auto']}` +- `sizing="full"` → `snapPoints={['full']}` +- `sizing={[…]}` → `snapPoints={[…]}` +- `onSnapPointChange` → `setActiveSnapPoint` (matches Vaul’s controlled-setter convention) +- `SnapPointValue` type → `SnapPoint` +- `DrawerSizing` / `DrawerSizingPreset` types removed +- `SNAP_POINT.FULL` (was `0.9`) → either `SNAP_POINT.NEAR_FULL` (the same `0.9` value) or `SNAP_POINT.MAX` (`1`, full height). The new `SNAP_POINT.FULL` token resolves to `'full'`. + +### Earlier: BottomSheet → Drawer + +The `BottomSheet` compatibility layer and `BOTTOM_SHEET_*` names were removed in v1. Use the `Drawer` API everywhere, and update any custom CSS or `data-*` hooks that targeted the old names. - **Components**: `BottomSheet` → `Drawer`, `BottomSheetContent` / `Handle` / `Overlay` / `Scrollable` → the corresponding `Drawer*` exports, composed as `Drawer.Content` and `Drawer`’s other static properties. - **Context / hooks**: `useBottomSheetContext` → `useDrawerContext`, `useBottomSheetDrag` → `useDrawerDrag`, `useBottomSheetSnap` → `useDrawerSnap`, `useBottomSheetKeyboardSnapMobile` → `useDrawerKeyboardSnapMobile`. (There is no separate `BottomSheetContext` export; the underlying context is `DrawerContext` if you need it in advanced code.) -- **Types**: all `BottomSheet*` types → the matching `Drawer*` / `*Drawer*` names (e.g. `BottomSheetProps` → `DrawerProps`, `BottomSheetRef` → `DrawerRef`, `BottomSheetSizing` → `DrawerSizing`). -- **Constants**: `BOTTOM_SHEET_SIZING` → `DRAWER_SIZING`, `BOTTOM_SHEET_TOP_INSET_PX` → `DRAWER_TOP_INSET_PX`, `BOTTOM_SHEET_CONTEXT_CONSUMER` → `DRAWER_CONTEXT_CONSUMER` (kept in source; not re-exported from the package `index` — use `Drawer` and its parts in normal use), `BOTTOM_SHEET_DRAG_SLOP_PX` → `DRAWER_DRAG_SLOP_PX` (internal tuning constant). +- **Types**: all `BottomSheet*` types → the matching `Drawer*` / `*Drawer*` names (e.g. `BottomSheetProps` → `DrawerProps`, `BottomSheetRef` → `DrawerRef`). +- **Constants**: `BOTTOM_SHEET_TOP_INSET_PX` → `DRAWER_TOP_INSET_PX`, `BOTTOM_SHEET_CONTEXT_CONSUMER` → `DRAWER_CONTEXT_CONSUMER` (kept in source; not re-exported from the package `index` — use `Drawer` and its parts in normal use), `BOTTOM_SHEET_DRAG_SLOP_PX` → `DRAWER_DRAG_SLOP_PX` (internal tuning constant). - **Data attributes**: `data-bottom-sheet-scroll` → `data-drawer-scroll`, `data-bottom-sheet-no-drag` → `data-drawer-no-drag`. - **CSS custom properties** on the motion panel: `--bottom-sheet-height` → `--drawer-height`, `--bottom-sheet-progress` → `--drawer-progress`, `--bottom-sheet-available-height` → `--drawer-available-height`. New: `--drawer-layout-bottom-inset` (visual-viewport bottom anchoring; see *Mobile Safari, soft keyboard* above). diff --git a/package.json b/package.json index e49c43d..221928f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@present-day/drawer", - "version": "1.2.10", + "version": "2.0.0", "description": "A flexible drawer component with smooth animations and snap points", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/playground/src/PlaygroundApp.tsx b/playground/src/PlaygroundApp.tsx index 5630fa6..b6f15bd 100644 --- a/playground/src/PlaygroundApp.tsx +++ b/playground/src/PlaygroundApp.tsx @@ -1,11 +1,10 @@ import { - DRAWER_SIZING, type DragEndInfo, Drawer, type DrawerProps, type DrawerRef, SNAP_POINT, - type SnapPointValue, + type SnapPoint, } from '@present-day/drawer' import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' @@ -84,8 +83,9 @@ function searchWithListContent(kind: 'auto' | 'snaps') { {kind === 'auto' ? ( <> Focus the field to show the on-screen keyboard. With{' '} - sizing=auto, height follows - content and the sheet should stay aligned above the keyboard. + snapPoints=['auto'], height + follows content and the sheet should stay aligned above the + keyboard. ) : ( <> @@ -170,9 +170,9 @@ function ImperativeDemo({ key="imperative" open={open} onOpenChange={onOpenChange} - sizing={[0.35, 0.6, 0.9]} + snapPoints={[0.35, 0.6, 0.9]} title="Imperative API" - onSnapPointChange={(p, i) => onLog(`snap: raw=${p} index=${i}`)} + setActiveSnapPoint={(p, i) => onLog(`snap: raw=${p} index=${i}`)} onAnimationComplete={(p) => onLog(`anim done: ${p}`)} onDragEnd={(_e, info) => onLog( @@ -239,7 +239,7 @@ function ControlledSnapDemo({ onOpenChange: (open: boolean) => void onLog: (line: string) => void }) { - const [active, setActive] = useState(0.3) + const [active, setActive] = useState(0.5) return ( <> {open ? ( @@ -268,12 +268,12 @@ function ControlledSnapDemo({ key="controlled" open={open} onOpenChange={onOpenChange} - sizing={[0.25, 0.5, 0.75]} - defaultSnapPoint={0.3} + snapPoints={[0.25, 0.5, 0.75]} + defaultSnapPoint={0.5} activeSnapPoint={active} - onSnapPointChange={(p, i) => { + setActiveSnapPoint={(p, i) => { setActive(p) - onLog(`onSnapPointChange: raw=${p} i=${i}`) + onLog(`setActiveSnapPoint: raw=${p} i=${i}`) }} > {baseContent('Active snap is driven by the buttons in the top-right', [ @@ -314,9 +314,9 @@ function AutoLoadingDemo({ key="auto-loading" open={open} onOpenChange={onOpenChange} - sizing={DRAWER_SIZING.AUTO} + snapPoints={['auto']} title="AUTO and async content" - onSnapPointChange={(p, i) => onLog(`onSnapPointChange: ${p} i=${i}`)} + setActiveSnapPoint={(p, i) => onLog(`setActiveSnapPoint: ${p} i=${i}`)} onAnimationComplete={(p) => onLog(`onAnimationComplete: snap ${String(p)}`) } @@ -355,7 +355,7 @@ function AutoLoadingDemo({ } /** - * Mixed sizing: `'auto'` as one snap, plus explicit pixel/full stops above it. + * Mixed snap points: `'auto'` as one snap, plus explicit pixel/full stops above it. * Demonstrates the loading→taller transition where the auto stop tracks the * measured content while the higher stops remain fixed and exceed the content. * @@ -399,10 +399,10 @@ function SnapsLoadingTallerThanContentDemo({ // the top stop fills the available drawer area ('full'). Default opens // at 480px — taller than the loading skeleton, so you can verify the // 'auto' slot moves in/out beneath the active snap as content settles. - sizing={[SNAP_POINT.AUTO, 480, DRAWER_SIZING.FULL]} + snapPoints={[SNAP_POINT.AUTO, 480, SNAP_POINT.FULL]} defaultSnapPoint={480} - title="Mixed sizing: AUTO + pixel + FULL" - onSnapPointChange={(p, i) => onLog(`onSnapPointChange: ${p} i=${i}`)} + title="Mixed snap points: AUTO + pixel + FULL" + setActiveSnapPoint={(p, i) => onLog(`setActiveSnapPoint: ${p} i=${i}`)} onAnimationComplete={(p) => onLog(`onAnimationComplete: snap ${String(p)}`) } @@ -411,7 +411,7 @@ function SnapsLoadingTallerThanContentDemo({

- sizing=[AUTO, 480, FULL] · skeleton, then taller + snapPoints=[AUTO, 480, FULL] · skeleton, then taller

Default opens at 480px. Drag down to land on the AUTO slot @@ -468,7 +468,7 @@ function getScenarioDrawer( } > = { autoShort: { - drawer: { sizing: DRAWER_SIZING.AUTO }, + drawer: { snapPoints: ['auto'] }, children: baseContent('Content-sized (AUTO)', [

A short body — height follows intrinsic content. Resize the window to @@ -477,23 +477,25 @@ function getScenarioDrawer( ]), }, autoLong: { - drawer: { sizing: DRAWER_SIZING.AUTO }, + drawer: { snapPoints: ['auto'] }, children: longScrollableContent(), }, autoSearch: { drawer: { - sizing: DRAWER_SIZING.AUTO, + snapPoints: ['auto'], title: 'Search', description: 'AUTO height with a search field — keyboard test on iOS', }, children: searchWithListContent('auto'), }, full: { - drawer: { sizing: DRAWER_SIZING.FULL }, + drawer: { snapPoints: ['full'] }, children: longScrollableContent(), }, snapsFractions: { - drawer: { sizing: [SNAP_POINT.PEEK, SNAP_POINT.HALF, SNAP_POINT.MAX] }, + drawer: { + snapPoints: [SNAP_POINT.PEEK, SNAP_POINT.HALF, SNAP_POINT.MAX], + }, children: baseContent('Fractions + px-style constants', [

Snaps: PEEK (80px), HALF, MAX. Flick up/down to change stops. @@ -501,7 +503,7 @@ function getScenarioDrawer( ]), }, snapsPixels: { - drawer: { sizing: [140, 300, 480] }, + drawer: { snapPoints: [140, 300, 480] }, children: baseContent('Fixed pixel stops', [

Values > 1 are read as pixel heights. Try slow drags to land @@ -511,7 +513,7 @@ function getScenarioDrawer( }, snapsSearch: { drawer: { - sizing: [0.45, 0.75, 0.96], + snapPoints: [0.45, 0.75, 0.96], defaultSnapPoint: 0.75, title: 'Search', description: 'Fractional snaps with a search field', @@ -519,7 +521,7 @@ function getScenarioDrawer( children: searchWithListContent('snaps'), }, defaultSnap: { - drawer: { sizing: [0.2, 0.45, 0.7], defaultSnapPoint: 0.45 }, + drawer: { snapPoints: [0.2, 0.45, 0.7], defaultSnapPoint: 0.45 }, children: baseContent('Opens at middle (defaultSnapPoint=0.45)', [

Close and re-open: intro animation should target the default stop. @@ -527,7 +529,7 @@ function getScenarioDrawer( ]), }, notDismissible: { - drawer: { sizing: [0.35, 0.65], dismissible: false }, + drawer: { snapPoints: [0.35, 0.65], dismissible: false }, children: baseContent('Cannot drag-dismiss', [

Dragging below the lowest snap should not close the panel. Use the bar @@ -544,7 +546,7 @@ function getScenarioDrawer( ]), }, nonModal: { - drawer: { modal: false, sizing: DRAWER_SIZING.AUTO }, + drawer: { modal: false, snapPoints: ['auto'] }, children: baseContent( 'Non-modal (no dimmer, no body lock by default pattern)', [ @@ -556,7 +558,7 @@ function getScenarioDrawer( ), }, topInset0: { - drawer: { topInsetPx: 0, sizing: [0.35, 0.65, 0.92] }, + drawer: { topInsetPx: 0, snapPoints: [0.35, 0.65, 0.92] }, children: baseContent('topInsetPx = 0', [

Full viewport height is available for snap math (no map chrome @@ -568,7 +570,7 @@ function getScenarioDrawer( drawer: { title: 'Accessible title for screen readers', description: 'Optional description string linked to the dialog.', - sizing: [0.4, 0.7], + snapPoints: [0.4, 0.7], }, children: baseContent('Visually plain content', [

@@ -607,9 +609,9 @@ export function PlaygroundApp() { pushLog(`open scenario: ${id}`) } - const onSnapPointChange = useCallback( - (p: SnapPointValue, i: number) => { - pushLog(`onSnapPointChange: raw=${p} index=${i}`) + const setActiveSnapPoint = useCallback( + (p: SnapPoint, i: number) => { + pushLog(`setActiveSnapPoint: raw=${p} index=${i}`) }, [pushLog], ) @@ -673,7 +675,7 @@ export function PlaygroundApp() { open={open} onOpenChange={handleOpenChange} {...d} - onSnapPointChange={onSnapPointChange} + setActiveSnapPoint={setActiveSnapPoint} onDragEnd={onDragEnd} onAnimationComplete={(p) => pushLog(`onAnimationComplete: ${p}`)} > @@ -723,7 +725,7 @@ export function PlaygroundApp() { /> openScenario('snapsLoadingTallerThanContent')} /> { { nm @@ -101,7 +100,7 @@ describe('Drawer (coverage)', () => { out @@ -122,7 +121,7 @@ describe('Drawer (coverage)', () => { open dismissible={false} onOpenChange={onOpenChange} - sizing={DRAWER_SIZING.FULL} + snapPoints={['full']} > x , @@ -135,7 +134,7 @@ describe('Drawer (coverage)', () => { it('ignores drags on interactive nodes and on scrolled scroll regions, and right mouse button', async () => { const onOpenChange = vi.fn() render( - +

no
@@ -183,7 +182,7 @@ describe('Drawer (coverage)', () => { @@ -213,7 +212,12 @@ describe('Drawer (coverage)', () => { const ref = createRef() const onOpenChange = vi.fn() render( - + z , ) @@ -233,7 +237,7 @@ describe('Drawer (coverage)', () => { setVisualViewportSize(900, 0) const onOpenChange = vi.fn() render( - + q , ) @@ -250,7 +254,7 @@ describe('Drawer (coverage)', () => { setVisualViewportSize(700, 10) const onOpenChange = vi.fn() render( - + kv , ) diff --git a/src/components/Drawer.test.tsx b/src/components/Drawer.test.tsx index cbf691c..14cdeee 100644 --- a/src/components/Drawer.test.tsx +++ b/src/components/Drawer.test.tsx @@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event' import { createRef } from 'react' import { describe, expect, it, vi } from 'vitest' -import { DRAWER_SIZING } from '../constants' import type { DrawerRef } from '../types' import { Drawer } from './Drawer' @@ -11,11 +10,7 @@ describe('Drawer', () => { it('renders nothing when closed', () => { const onOpenChange = vi.fn() render( - + Panel , ) @@ -27,7 +22,7 @@ describe('Drawer', () => { const user = userEvent.setup() render( - + Hello drawer @@ -57,7 +52,7 @@ describe('Drawer', () => { const ref = createRef() render( - + Ref test , ) @@ -79,7 +74,7 @@ describe('Drawer', () => { @@ -105,7 +100,7 @@ describe('Drawer', () => { Body @@ -120,7 +115,7 @@ describe('Drawer', () => { const onOpenChange = vi.fn() const user = userEvent.setup() render( - + Body , ) diff --git a/src/components/Drawer.tsx b/src/components/Drawer.tsx index 8a5552d..97935f4 100644 --- a/src/components/Drawer.tsx +++ b/src/components/Drawer.tsx @@ -25,7 +25,6 @@ import { import { createPortal } from 'react-dom' import { DRAWER_DRAG_SLOP_PX, - DRAWER_SIZING, DRAWER_TOP_INSET_PX, RUBBER_BAND_FACTOR, SPRING_CONFIG, @@ -34,12 +33,10 @@ import { DrawerContext, type DrawerContextValue } from '../context' import { DrawerSlotsProvider } from '../drawerSlotsContext' import { resolveSnapAfterDrag, useDrawerSnap } from '../hooks/useDrawerSnap' import { useVisualViewport } from '../hooks/useVisualViewport' -import type { - DragEndInfo, - DrawerProps, - DrawerRef, - SnapPointValue, -} from '../types' +import type { DragEndInfo, DrawerProps, DrawerRef, SnapPoint } from '../types' + +const DEFAULT_SNAP_POINTS: readonly SnapPoint[] = ['auto'] + import { cn, getLockCount, lockBody, unlockBody } from '../utils' import { DrawerContent } from './drawer/DrawerContent' import { DrawerHandle } from './drawer/DrawerHandle' @@ -49,9 +46,10 @@ import { DrawerScrollable } from './drawer/DrawerScrollable' const DrawerRoot = forwardRef( function DrawerRoot(props, ref) { const { - open, - onOpenChange, - sizing = DRAWER_SIZING.AUTO, + open: controlledOpen, + onOpenChange: onOpenChangeProp, + defaultOpen, + snapPoints = DEFAULT_SNAP_POINTS, defaultSnapPoint, activeSnapPoint, dismissible = true, @@ -68,11 +66,29 @@ const DrawerRoot = forwardRef( overlayClassName, slots, focusTrap = true, + handleOnly = false, + fadeFromIndex, + snapToSequentialPoint = false, + nested = false, ariaLabel, title, description, } = props + // Uncontrolled open state — used when `open` is not provided. + const isControlled = controlledOpen !== undefined + const [uncontrolledOpen, setUncontrolledOpen] = useState( + () => defaultOpen ?? false, + ) + const open = isControlled ? (controlledOpen as boolean) : uncontrolledOpen + const onOpenChange = useCallback( + (nextOpen: boolean) => { + if (!isControlled) setUncontrolledOpen(nextOpen) + onOpenChangeProp?.(nextOpen) + }, + [isControlled, onOpenChangeProp], + ) + const reduceMotion = useReducedMotion() const measureRef = useRef(null) const lastMeasureElRef = useRef(null) @@ -111,7 +127,7 @@ const DrawerRoot = forwardRef( resolveSnapToIndex, indexToRawValue, } = useDrawerSnap({ - sizing, + snapPoints, viewportHeight: viewport.height || availableHeight, topInsetPx, defaultSnapPoint, @@ -133,9 +149,7 @@ const DrawerRoot = forwardRef( // the rawValues memo invalidates) would clobber the mirror with the // new-position raw before the remap effect could read the old one. // Snapshotting the prior array sidesteps that ordering hazard entirely. - const prevRawSnapValuesRef = useRef( - null, - ) + const prevRawSnapValuesRef = useRef(null) const prevSnapIndexRef = useRef(defaultIndex) const heightMv = useMotionValue(0) const dragHeightStartRef = useRef(0) @@ -228,12 +242,21 @@ const DrawerRoot = forwardRef( ( nextIdx: number, dragMeta?: { velocityY: number; endY: number; progress: number }, + // `notify: false` is used by the controlled-mode `activeSnapPoint` + // effect: there the parent is the source of truth and already knows + // which stop it asked for, so calling `onSnapPointChange` would be + // redundant (and risks a feedback loop for callers that derive other + // state from the callback). + notify: boolean = true, ) => { const targetH = snapHeights[nextIdx] ?? minSnap const fromH = heightMv.get() onAnimationStart?.(fromH, targetH) const raw = indexToRawValue(nextIdx) + if (raw !== null && notify) { + onSnapPointChange?.(raw, nextIdx) + } if (dragMeta && raw !== null) { const dragInfo: DragEndInfo = { y: dragMeta.endY, @@ -245,7 +268,6 @@ const DrawerRoot = forwardRef( new PointerEvent('pointerup') as unknown as PointerEvent, dragInfo, ) - onSnapPointChange?.(raw, nextIdx) } animate(heightMv, targetH, { @@ -293,15 +315,32 @@ const DrawerRoot = forwardRef( } return } - const h = snapHeights[defaultIndex] ?? minSnap + // Honor a controlled activeSnapPoint on open: use it as the intro target + // so we snap to the parent's requested stop, not just defaultIndex. + // Fall back to defaultIndex when activeSnapPoint is not provided. + const targetIndex = + activeSnapPoint != null + ? resolveSnapToIndex(activeSnapPoint) + : defaultIndex + const h = snapHeights[targetIndex] // Require a positive measurement; the previous `< 32` guard prevented // intro from starting for short content (e.g. AUTO with a tight loading // skeleton), which also blocked `resnapReady` and stopped the resnap // effect from animating to taller content once it loaded. - if (h <= 0) return + if (h === undefined || h <= 0) return if (introStartedRef.current) return introStartedRef.current = true - setSnapIndex(defaultIndex) + setSnapIndex(targetIndex) + // Notify controllers only in uncontrolled mode — if activeSnapPoint is + // already controlled the parent is the source of truth and already knows + // which stop was requested; echoing it back is redundant and risks a + // feedback loop for callers that derive other state from the callback. + if (activeSnapPoint == null) { + const introRaw = indexToRawValue(targetIndex) + if (introRaw !== null) { + onSnapPointChange?.(introRaw, targetIndex) + } + } heightMv.set(0) updateProgress(0) animate(heightMv, h, { @@ -314,10 +353,13 @@ const DrawerRoot = forwardRef( }) }, [ open, + activeSnapPoint, defaultIndex, - minSnap, + resolveSnapToIndex, snapHeights, heightMv, + indexToRawValue, + onSnapPointChange, spring, updateProgress, ]) @@ -388,9 +430,9 @@ const DrawerRoot = forwardRef( const prevRaw = prevArr?.[prevIdx] if (prevRaw !== undefined) { const newIdx = rawSnapValues.indexOf(prevRaw) - // -1: previous raw is gone (sizing prop changed). Leave snapIndex - // alone — the resnap / activeSnapPoint effects will land it - // somewhere sensible. + // -1: previous raw is gone (snapPoints prop changed). Leave + // snapIndex alone — the resnap / activeSnapPoint effects will land + // it somewhere sensible. if (newIdx >= 0 && newIdx !== snapIndex) { setSnapIndex(newIdx) } @@ -401,7 +443,7 @@ const DrawerRoot = forwardRef( prevSnapIndexRef.current = snapIndex }, [rawSnapValues, snapIndex]) - const lastActiveSnapRef = useRef(undefined) + const lastActiveSnapRef = useRef(undefined) useEffect(() => { if (!open) { lastActiveSnapRef.current = undefined @@ -414,7 +456,10 @@ const DrawerRoot = forwardRef( lastActiveSnapRef.current = activeSnapPoint const idx = resolveSnapToIndex(activeSnapPoint) setSnapIndex(idx) - snapToHeightAnimated(idx) + // Parent-driven change: skip the `onSnapPointChange` notification — + // the parent already knows it just set this value and will receive a + // redundant call (or worse, churn) if we echo it back. + snapToHeightAnimated(idx, undefined, /* notify */ false) }, [activeSnapPoint, open, resolveSnapToIndex, snapToHeightAnimated]) useEffect(() => { @@ -529,6 +574,8 @@ const DrawerRoot = forwardRef( velocityY, heightsAsc: snapHeights, dismissible, + currentSnapIndex: snapIndex, + snapToSequentialPoint, }) if (decision.type === 'dismiss') { @@ -565,7 +612,9 @@ const DrawerRoot = forwardRef( minSnap, onOpenChange, snapHeights, + snapIndex, snapToHeightAnimated, + snapToSequentialPoint, spring, updateProgress, ], @@ -574,7 +623,7 @@ const DrawerRoot = forwardRef( useImperativeHandle( ref, () => ({ - snapTo: (point: SnapPointValue) => { + snapTo: (point: SnapPoint) => { const idx = resolveSnapToIndex(point) setSnapIndex(idx) snapToHeightAnimated(idx) @@ -606,13 +655,20 @@ const DrawerRoot = forwardRef( const shouldIgnorePointerTarget = useCallback( (target: EventTarget | null) => { if (!(target instanceof Element)) return true - return Boolean( + if ( target.closest( 'button, a, input, textarea, select, [contenteditable="true"], [data-drawer-no-drag]', - ), - ) + ) + ) { + return true + } + // handleOnly: only allow drag to start from within a Drawer.Handle element. + if (handleOnly && !target.closest('[data-drawer-handle]')) { + return true + } + return false }, - [], + [handleOnly], ) const handleDrawerPointerDown = useCallback( @@ -733,6 +789,25 @@ const DrawerRoot = forwardRef( [heightMv, runSnapFromVisible], ) + // Overlay opacity — when `fadeFromIndex` is set, the overlay fades in + // proportionally as the drawer rises from the stop below `fadeFromIndex` + // to `snapHeights[fadeFromIndex]`. Below that band the overlay is invisible; + // at or above it the overlay is fully opaque. When unset, the overlay uses + // the standard open/close animation (opacity 0 → 1). + // + // `heightState` updates every frame during drag (via `useMotionValueEvent`), + // so this memo tracks the current height without an additional motion value. + // The overlay uses `transition={{ duration: 0 }}` in this mode so the + // `animate` target jumps instantly to match without a trailing spring. + const overlayTargetOpacity = useMemo(() => { + if (fadeFromIndex === undefined) return 1 + const upper = snapHeights[fadeFromIndex] ?? 0 + const lower = + fadeFromIndex > 0 ? (snapHeights[fadeFromIndex - 1] ?? 0) : 0 + if (upper <= lower) return 1 + return Math.max(0, Math.min(1, (heightState - lower) / (upper - lower))) + }, [fadeFromIndex, snapHeights, heightState]) + if (!mounted) { return null } @@ -752,9 +827,13 @@ const DrawerRoot = forwardRef( aria-hidden tabIndex={-1} initial={{ opacity: 0 }} - animate={{ opacity: 1 }} + animate={{ opacity: overlayTargetOpacity }} exit={{ opacity: 0 }} - transition={reduceMotion ? { duration: 0 } : { duration: 0.2 }} + transition={ + fadeFromIndex !== undefined || reduceMotion + ? { duration: 0 } + : { duration: 0.2 } + } className={cn( 'fixed inset-0 z-50 bg-black/50', overlayClassName, @@ -787,10 +866,24 @@ const DrawerRoot = forwardRef( 'fixed inset-x-0 z-50 flex max-h-dvh touch-none flex-col outline-none pointer-events-auto overscroll-y-none', )} onKeyDown={handleDialogKeyDown} - onPointerDown={handleDrawerPointerDown} - onPointerMove={handleDrawerPointerMove} - onPointerUp={endDragSession} - onPointerCancel={endDragSession} + onPointerDown={(e) => { + // Prevent the outer drawer from picking up this gesture when + // this drawer is nested inside another. + if (nested) e.stopPropagation() + handleDrawerPointerDown(e) + }} + onPointerMove={(e) => { + if (nested) e.stopPropagation() + handleDrawerPointerMove(e) + }} + onPointerUp={(e) => { + if (nested) e.stopPropagation() + endDragSession(e) + }} + onPointerCancel={(e) => { + if (nested) e.stopPropagation() + endDragSession(e) + }} > {title != null ? ( diff --git a/src/components/drawer/DrawerParts.test.tsx b/src/components/drawer/DrawerParts.test.tsx index d388981..3ec9300 100644 --- a/src/components/drawer/DrawerParts.test.tsx +++ b/src/components/drawer/DrawerParts.test.tsx @@ -2,7 +2,6 @@ import { render, screen, waitFor } from '@testing-library/react' import { createRef } from 'react' import { describe, expect, it, vi } from 'vitest' -import { DRAWER_SIZING } from '../../constants' import { Drawer } from '../Drawer' const slots = { @@ -14,12 +13,7 @@ const slots = { describe('Drawer parts & slots', () => { it('applies slot class names in merge order: defaults → slots → part props', async () => { render( - + { it('renders custom handle children instead of the default bar', () => { render( - + grab @@ -67,7 +61,7 @@ describe('Drawer parts & slots', () => { const contentRef = createRef() const scrollRef = createRef() render( - + a @@ -86,7 +80,7 @@ describe('Drawer parts & slots', () => { const handleCb = vi.fn() function ScrollWrapper() { return ( - + { diff --git a/src/constants.ts b/src/constants.ts index afa12ac..eae77f1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,37 +1,40 @@ +import type { SnapPoint } from './types' + /** Labels for `useDrawerContext` error messages (keep in sync with compound components). */ export const DRAWER_CONTEXT_CONSUMER = { Content: 'Drawer.Content', Scrollable: 'Drawer.Scrollable', } as const -/** Preset `sizing` modes for `Drawer` (content-sized, full height, or explicit snap array). */ -export const DRAWER_SIZING = { - AUTO: 'auto', - FULL: 'full', -} as const - +/** + * Predefined snap points for the `Drawer`’s `snapPoints` prop. + * + * Each member is a {@link SnapPoint}: a pixel value (`> 1`), a fraction of + * available height (`≤ 1`), or one of the string tokens `'auto'` / `'full'`. + * + * - `AUTO` — content-fit (live `ResizeObserver` measurement) + * - `PEEK` — small fixed peek (80px) + * - `QUARTER` / `THIRD` / `HALF` / `TWO_THIRDS` / `THREE_QUARTERS` — fractions + * - `NEAR_FULL` — `0.9` of available height (was `SNAP_POINT.FULL` pre-v2) + * - `MAX` — full available drawer height as a fraction (`1`) + * - `FULL` — full available drawer height as a token; equivalent to `MAX` + * but composes with mixed arrays like `['auto', 480, 'full']` + * + * @example + * + */ export const SNAP_POINT = { + AUTO: 'auto', PEEK: 80, QUARTER: 0.25, THIRD: 0.33, HALF: 0.5, TWO_THIRDS: 0.66, THREE_QUARTERS: 0.75, - FULL: 0.9, + NEAR_FULL: 0.9, MAX: 1, - /** - * Resolves to the measured intrinsic content height (the same value - * `DRAWER_SIZING.AUTO` produces). Use as one snap stop within a sizing - * array to mix a content-fit stop with explicit pixel/fraction stops: - * `sizing={[SNAP_POINT.AUTO, 480, DRAWER_SIZING.FULL]}`. - * - * Note: `DRAWER_SIZING.FULL` is also accepted inside the snap array (it - * resolves to the full available drawer height). It is intentionally not - * re-exported here as `SNAP_POINT.FULL` because `SNAP_POINT.FULL` already - * exists with a different value (`0.9`). - */ - AUTO: 'auto', -} as const + FULL: 'full', +} as const satisfies Record export const SPRING_CONFIG = { default: { stiffness: 400, damping: 40, mass: 1 }, diff --git a/src/hooks/useDrawerKeyboardSnapMobile.ts b/src/hooks/useDrawerKeyboardSnapMobile.ts index 81b13db..204e1f3 100644 --- a/src/hooks/useDrawerKeyboardSnapMobile.ts +++ b/src/hooks/useDrawerKeyboardSnapMobile.ts @@ -3,7 +3,7 @@ import { type RefObject, useCallback, useEffect, useRef } from 'react' import { SNAP_POINT } from '../constants' -import type { DrawerRef, SnapPointValue, ViewportInfo } from '../types' +import type { DrawerRef, SnapPoint, ViewportInfo } from '../types' type Options = { open: boolean @@ -21,10 +21,10 @@ export function useDrawerKeyboardSnapMobile({ drawerRef, }: Options) { const wasKeyboardOpenRef = useRef(false) - // SnapPointValue widened to include `'auto'` / `'full'` — the saved snap + // `SnapPoint` is widened to include `'auto'` / `'full'` — the saved snap // could be either a numeric stop or one of those tokens, so the restore // target must also accept the broader type. - const snapBeforeKeyboardRef = useRef(null) + const snapBeforeKeyboardRef = useRef(null) useEffect(() => { if (!open || !isMobile) { diff --git a/src/hooks/useDrawerSnap.hook.test.tsx b/src/hooks/useDrawerSnap.hook.test.tsx index fac2bcc..537bf97 100644 --- a/src/hooks/useDrawerSnap.hook.test.tsx +++ b/src/hooks/useDrawerSnap.hook.test.tsx @@ -2,14 +2,13 @@ import { renderHook, waitFor } from '@testing-library/react' import { createRef } from 'react' import { describe, expect, it } from 'vitest' -import { DRAWER_SIZING } from '../constants' import { useDrawerSnap } from './useDrawerSnap' describe('useDrawerSnap (hook)', () => { it('exposes defaultIndex 0 when there are no snap heights', () => { const { result } = renderHook(() => useDrawerSnap({ - sizing: [], + snapPoints: [], viewportHeight: 800, topInsetPx: 0, defaultSnapPoint: 0.5, @@ -23,7 +22,7 @@ describe('useDrawerSnap (hook)', () => { it('returns null from indexToRawValue for an out-of-range index', () => { const { result } = renderHook(() => useDrawerSnap({ - sizing: [0.2, 0.4, 0.6], + snapPoints: [0.2, 0.4, 0.6], viewportHeight: 800, topInsetPx: 0, contentMeasureRef: { current: null }, @@ -35,7 +34,7 @@ describe('useDrawerSnap (hook)', () => { it('finds a snap index for a raw snap point', () => { const { result } = renderHook(() => useDrawerSnap({ - sizing: [0.25, 0.75], + snapPoints: [0.25, 0.75], viewportHeight: 800, topInsetPx: 0, contentMeasureRef: { current: null }, @@ -47,7 +46,7 @@ describe('useDrawerSnap (hook)', () => { expect(idx1).toBe(1) }) - it('wires AUTO sizing to ResizeObserver on the content element', async () => { + it("wires ['auto'] to ResizeObserver on the content element", async () => { const prevRo = globalThis.ResizeObserver const Obs = class { _cb: ResizeObserverCallback @@ -81,7 +80,7 @@ describe('useDrawerSnap (hook)', () => { const { result } = renderHook(() => useDrawerSnap({ - sizing: DRAWER_SIZING.AUTO, + snapPoints: ['auto'], viewportHeight: 800, topInsetPx: 0, contentMeasureRef: ref, @@ -100,7 +99,7 @@ describe('useDrawerSnap (hook)', () => { it('defaultIndex is the last snap when defaultSnapPoint is omitted', () => { const { result } = renderHook(() => useDrawerSnap({ - sizing: [0.1, 0.3, 0.5], + snapPoints: [0.1, 0.3, 0.5], viewportHeight: 800, topInsetPx: 0, contentMeasureRef: { current: null }, @@ -111,10 +110,10 @@ describe('useDrawerSnap (hook)', () => { expect(result.current.defaultIndex).toBe(n - 1) }) - it('does not start ResizeObserver when the measure ref is null (AUTO)', () => { + it("does not start ResizeObserver when the measure ref is null (['auto'])", () => { const { result } = renderHook(() => useDrawerSnap({ - sizing: DRAWER_SIZING.AUTO, + snapPoints: ['auto'], viewportHeight: 800, topInsetPx: 0, contentMeasureRef: { current: null }, @@ -161,7 +160,7 @@ describe('useDrawerSnap (hook)', () => { const { result, rerender } = renderHook( ({ gen }: { gen: number }) => useDrawerSnap({ - sizing: DRAWER_SIZING.AUTO, + snapPoints: ['auto'], viewportHeight: 800, topInsetPx: 0, contentMeasureRef: ref, diff --git a/src/hooks/useDrawerSnap.test.ts b/src/hooks/useDrawerSnap.test.ts index 37e7d01..70adfc8 100644 --- a/src/hooks/useDrawerSnap.test.ts +++ b/src/hooks/useDrawerSnap.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' -import { DRAWER_SIZING, VELOCITY_THRESHOLD } from '../constants' +import { VELOCITY_THRESHOLD } from '../constants' import { heightToSnapRawValue, maxDescendantScrollOverflow, measureIntrinsicAutoHeight, - resolveSizingToHeights, resolveSnapAfterDrag, + resolveSnapPointsToHeights, resolveSnapValueToPx, } from './useDrawerSnap' @@ -143,45 +143,46 @@ describe('resolveSnapValueToPx', () => { }) }) -describe('resolveSizingToHeights', () => { - it('FULL returns a single height capped to available space', () => { - const { heights, rawValues } = resolveSizingToHeights( - DRAWER_SIZING.FULL, - 700, - ) +describe('resolveSnapPointsToHeights', () => { + it('returns empty arrays when given an empty snap array', () => { + const { heights, rawValues } = resolveSnapPointsToHeights([], 700) + expect(heights).toEqual([]) + expect(rawValues).toEqual([]) + }) + + it("['full'] returns a single height capped to available space", () => { + const { heights, rawValues } = resolveSnapPointsToHeights(['full'], 700) expect(heights).toEqual([700]) - expect(rawValues).toEqual([1]) + expect(rawValues).toEqual(['full']) }) - it('AUTO uses zero fallback when no measured height yet', () => { - const { heights } = resolveSizingToHeights(DRAWER_SIZING.AUTO, 800, null) + it("['auto'] uses zero fallback when no measured height yet", () => { + const { heights } = resolveSnapPointsToHeights(['auto'], 800, null) expect(heights.length).toBe(1) expect(heights[0]).toBe(0) expect(heights[0]).toBeLessThanOrEqual(800) }) - it('AUTO uses a small positive measured height (no 200px minimum shelf)', () => { - const { heights, rawValues } = resolveSizingToHeights( - DRAWER_SIZING.AUTO, - 800, - 48, - ) + it("['auto'] uses a small positive measured height (no 200px minimum shelf)", () => { + const { heights, rawValues } = resolveSnapPointsToHeights(['auto'], 800, 48) expect(heights).toEqual([48]) - expect(rawValues).toEqual([48]) + // raw stays as 'auto' so the slot keeps tracking the live measurement + // after restores (e.g. via useDrawerKeyboardSnapMobile). + expect(rawValues).toEqual(['auto']) }) - it('AUTO uses measured height when content is substantial', () => { - const { heights, rawValues } = resolveSizingToHeights( - DRAWER_SIZING.AUTO, + it("['auto'] uses measured height when content is substantial", () => { + const { heights, rawValues } = resolveSnapPointsToHeights( + ['auto'], 800, 360, ) expect(heights).toEqual([360]) - expect(rawValues).toEqual([360]) + expect(rawValues).toEqual(['auto']) }) it('dedupes snap points and sorts ascending', () => { - const { heights, rawValues } = resolveSizingToHeights( + const { heights, rawValues } = resolveSnapPointsToHeights( [0.25, 0.5, 0.25, 0.75], 400, ) @@ -190,7 +191,7 @@ describe('resolveSizingToHeights', () => { }) it("array with 'auto' substitutes the measured content height", () => { - const { heights, rawValues } = resolveSizingToHeights( + const { heights, rawValues } = resolveSnapPointsToHeights( ['auto', 480, 0.92], 1000, 280, @@ -201,21 +202,24 @@ describe('resolveSizingToHeights', () => { }) it("array with 'full' resolves to the full available height", () => { - const { heights, rawValues } = resolveSizingToHeights(['full', 200], 800) + const { heights, rawValues } = resolveSnapPointsToHeights( + ['full', 200], + 800, + ) expect(heights).toEqual([200, 800]) expect(rawValues).toEqual([200, 'full']) }) it("'auto' inside an array updates as measured height changes", () => { - const { heights: a } = resolveSizingToHeights(['auto', 480], 800, 100) - const { heights: b } = resolveSizingToHeights(['auto', 480], 800, 600) + const { heights: a } = resolveSnapPointsToHeights(['auto', 480], 800, 100) + const { heights: b } = resolveSnapPointsToHeights(['auto', 480], 800, 600) expect(a).toEqual([100, 480]) // 'auto' grew past 480; resort puts auto last expect(b).toEqual([480, 600]) }) it("'auto' caps at the available viewport even if measured is taller", () => { - const { heights } = resolveSizingToHeights(['auto', 0.5], 600, 1500) + const { heights } = resolveSnapPointsToHeights(['auto', 0.5], 600, 1500) // 'auto' clamped to 600, 0.5 -> 300 expect(heights).toEqual([300, 600]) }) diff --git a/src/hooks/useDrawerSnap.ts b/src/hooks/useDrawerSnap.ts index c214ff7..52b66b9 100644 --- a/src/hooks/useDrawerSnap.ts +++ b/src/hooks/useDrawerSnap.ts @@ -10,17 +10,16 @@ import { import { DISMISS_THRESHOLD_PX, - DRAWER_SIZING, DRAWER_TOP_INSET_PX, VELOCITY_THRESHOLD, } from '../constants' -import type { DrawerSizing, SnapPointValue } from '../types' +import type { SnapPoint } from '../types' /** Used only when we have not received a real measurement yet (0 / null). */ const AUTO_FALLBACK_HEIGHT_PX = 0 export function resolveSnapValueToPx( - value: SnapPointValue, + value: SnapPoint, availableHeight: number, measuredAutoHeight?: number | null, ): number { @@ -42,18 +41,39 @@ export function resolveSnapValueToPx( return Math.max(0, Math.round(value)) } -export function resolveSizingToHeights( - sizing: DrawerSizing, +/** + * Resolve a `snapPoints` array to ascending pixel heights and the raw values + * that produced them. The `'auto'` slot grows or shrinks with `measuredAutoHeight`; + * the `'full'` slot always equals the full available drawer height. Duplicate + * resolved heights are removed (first occurrence wins after sorting). + */ +export function resolveSnapPointsToHeights( + snapPoints: readonly SnapPoint[], availableHeight: number, measuredAutoHeight?: number | null, -): { heights: number[]; rawValues: SnapPointValue[] } { +): { heights: number[]; rawValues: SnapPoint[] } { const cap = Math.max(0, availableHeight) - if (sizing === DRAWER_SIZING.FULL) { - return { heights: [cap], rawValues: [1] } + // Empty array: caller passed `snapPoints={[]}` (rare but legal). Keep the + // hook output empty so the Drawer renders nothing snappable. + if (snapPoints.length === 0) { + return { heights: [], rawValues: [] } } - if (sizing === DRAWER_SIZING.AUTO) { + // Single 'auto' fast-path: this is the default sizing case. We don't take + // the generic path here because we want a 0 fallback height while the + // measurement is still pending — the generic path's dedupe-after-sort + // would still produce the same result, but this keeps the hot default + // free of array allocation/sort overhead. + // + // NOTE: rawValues preserves the original `'auto'` token (not the resolved + // pixel height). Downstream consumers — `getActiveSnapPoint()` and + // `useDrawerKeyboardSnapMobile` (saves the active snap before the keyboard + // opens, restores after it closes) — rely on the raw being `'auto'` so + // the slot continues to track measured content after restore. Returning + // the resolved pixel height here would freeze the stop at whatever size + // the content was at save time. + if (snapPoints.length === 1 && snapPoints[0] === 'auto') { const measured = Math.min( cap, Math.max(0, Math.round(measuredAutoHeight ?? 0)), @@ -63,17 +83,17 @@ export function resolveSizingToHeights( // "minimum shelf" used when measured < 120px caused short content to // jump to 200px. `measured === 0` means layout has not reported size yet. const resolvedHeight = measured > 0 ? measured : fallback - return { heights: [resolvedHeight], rawValues: [resolvedHeight] } + return { heights: [resolvedHeight], rawValues: ['auto'] } } - const pairs = sizing.map((raw) => ({ + const pairs = snapPoints.map((raw) => ({ raw, px: resolveSnapValueToPx(raw, cap, measuredAutoHeight), })) pairs.sort((a, b) => a.px - b.px) const heights: number[] = [] - const rawValues: SnapPointValue[] = [] + const rawValues: SnapPoint[] = [] for (const p of pairs) { if (!heights.includes(p.px)) { heights.push(p.px) @@ -86,10 +106,10 @@ export function resolveSizingToHeights( export function heightToSnapRawValue( heightPx: number, - rawValues: SnapPointValue[], + rawValues: SnapPoint[], heights: number[], availableHeight: number, -): SnapPointValue { +): SnapPoint { const idx = heights.indexOf(heightPx) const rawAtHeight = rawValues[idx] if (idx >= 0 && rawAtHeight !== undefined) { @@ -124,7 +144,7 @@ export function maxDescendantScrollOverflow(el: HTMLElement): number { } /** - * Intrinsic height for {@link DRAWER_SIZING.AUTO}: sums each **direct** child’s + * Intrinsic height for the `'auto'` snap point: sums each **direct** child’s * layout block size, using `scrollHeight` for `[data-drawer-scroll]` so the * value reflects full scrollable content instead of the flex-clamped layout * height (which `ResizeObserver`’s `contentRect` on the content root tracks). @@ -173,7 +193,9 @@ export function measureIntrinsicAutoHeight(root: HTMLElement): number { // `offsetHeight` so we don't inflate AUTO to the panel height for // children that currently stretch to fill it. const contribution = - withDescendantOverflow === 0 ? child.scrollHeight : withDescendantOverflow + withDescendantOverflow === 0 + ? child.scrollHeight + : withDescendantOverflow sum += contribution } } @@ -273,14 +295,29 @@ function nearestHeightIndex( /** * Pick snap index after drag end using velocity-weighted rules. + * + * When `snapToSequentialPoint` is true, fast swipes only advance one snap stop + * at a time rather than flying past intermediate stops. Slow drag (nearest-stop) + * is unaffected. */ export function resolveSnapAfterDrag(args: { visibleHeight: number velocityY: number heightsAsc: number[] dismissible: boolean + /** Current snap index — required when `snapToSequentialPoint` is true. */ + currentSnapIndex?: number + /** When true, velocity-based skipping is limited to one adjacent stop. */ + snapToSequentialPoint?: boolean }): { type: 'snap'; index: number } | { type: 'dismiss' } { - const { visibleHeight, velocityY, heightsAsc, dismissible } = args + const { + visibleHeight, + velocityY, + heightsAsc, + dismissible, + currentSnapIndex, + snapToSequentialPoint, + } = args if (heightsAsc.length === 0) { return { type: 'dismiss' } } @@ -303,6 +340,15 @@ export function resolveSnapAfterDrag(args: { } if (velocityY > VELOCITY_THRESHOLD) { + // Fast downward swipe. + if (snapToSequentialPoint && currentSnapIndex !== undefined) { + // Sequential mode: only go to the previous adjacent stop. + const prevIdx = currentSnapIndex - 1 + if (prevIdx < 0) { + return dismissible ? { type: 'dismiss' } : { type: 'snap', index: 0 } + } + return { type: 'snap', index: prevIdx } + } const below = heightsAsc.filter((h) => h < visibleHeight - 16) if (below.length > 0) { const li = below.length - 1 @@ -317,6 +363,12 @@ export function resolveSnapAfterDrag(args: { } if (velocityY < -VELOCITY_THRESHOLD) { + // Fast upward swipe. + if (snapToSequentialPoint && currentSnapIndex !== undefined) { + // Sequential mode: only go to the next adjacent stop. + const nextIdx = Math.min(currentSnapIndex + 1, heightsAsc.length - 1) + return { type: 'snap', index: nextIdx } + } const above = heightsAsc.filter((h) => h > visibleHeight + 16) const targetAbove = above[0] if (targetAbove !== undefined) { @@ -347,12 +399,12 @@ export function resolveSnapAfterDrag(args: { } export type UseDrawerSnapArgs = { - sizing: DrawerSizing + snapPoints: readonly SnapPoint[] /** Raw visual viewport height (before top inset) */ viewportHeight: number /** Subtracted from viewport height for snap math; default map inset. */ topInsetPx?: number - defaultSnapPoint?: SnapPointValue + defaultSnapPoint?: SnapPoint contentMeasureRef: RefObject /** * Bumped by the Drawer whenever the measure element attaches/detaches. @@ -365,7 +417,7 @@ export type UseDrawerSnapArgs = { } export function useDrawerSnap({ - sizing, + snapPoints, viewportHeight, topInsetPx = DRAWER_TOP_INSET_PX, defaultSnapPoint, @@ -378,12 +430,10 @@ export function useDrawerSnap({ null, ) - // Measurement is needed both for `DRAWER_SIZING.AUTO` and for any explicit - // snap array that contains an `'auto'` slot (mixed-mode sizing). `'full'` - // does not need measurement since it always resolves to availableHeight. - const needsAutoMeasurement = - sizing === DRAWER_SIZING.AUTO || - (Array.isArray(sizing) && sizing.includes('auto')) + // Measurement is needed any time `'auto'` appears in the snap array. Other + // tokens (`'full'`) and numeric stops are derivable from `availableHeight` + // alone. + const needsAutoMeasurement = snapPoints.includes('auto') useEffect(() => { if (!needsAutoMeasurement) return @@ -405,8 +455,13 @@ export function useDrawerSnap({ }, [contentMeasureRef, needsAutoMeasurement, measureAttachGeneration]) const { heights, rawValues } = useMemo( - () => resolveSizingToHeights(sizing, availableHeight, measuredAutoHeight), - [sizing, availableHeight, measuredAutoHeight], + () => + resolveSnapPointsToHeights( + snapPoints, + availableHeight, + measuredAutoHeight, + ), + [snapPoints, availableHeight, measuredAutoHeight], ) const defaultIndex = useMemo(() => { @@ -426,7 +481,7 @@ export function useDrawerSnap({ rawSnapValues: rawValues, defaultIndex, resolveSnapToIndex: useCallback( - (point: SnapPointValue) => { + (point: SnapPoint) => { const target = resolveSnapValueToPx( point, availableHeight, @@ -437,7 +492,7 @@ export function useDrawerSnap({ [availableHeight, heights, measuredAutoHeight], ), indexToRawValue: useCallback( - (index: number): SnapPointValue | null => { + (index: number): SnapPoint | null => { const h = heights[index] if (h === undefined) return null return heightToSnapRawValue(h, rawValues, heights, availableHeight) diff --git a/src/index.ts b/src/index.ts index ece2f86..662e021 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,6 @@ export { DrawerOverlay } from './components/drawer/DrawerOverlay' export { DrawerScrollable } from './components/drawer/DrawerScrollable' // Constants (for customization) export { - DRAWER_SIZING, DRAWER_TOP_INSET_PX, // Common constants SNAP_POINT, @@ -44,10 +43,8 @@ export type { // Common types DragInfo, DrawerDragEvent, - DrawerSizing, - DrawerSizingPreset, DrawerSlots, - SnapPointValue, + SnapPoint, ViewportInfo, } from './types' diff --git a/src/types.ts b/src/types.ts index 3a63714..9b7082e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,5 @@ import type { ReactNode, PointerEvent as ReactPointerEvent } from 'react' -import type { DRAWER_SIZING } from './constants' - /** * Optional class names applied from `Drawer`’s `slots` prop. Merge order for each * part is: package defaults → `slots.*` → part’s own `className` (and handle’s @@ -14,28 +12,62 @@ export type DrawerSlots = { } /** - * A snap point. Numeric values are fractions of available height when ≤ 1 - * and pixel heights when > 1. The string literals match - * {@link DRAWER_SIZING}: `'auto'` resolves to the measured intrinsic content - * height (live, via `ResizeObserver`); `'full'` resolves to the full available - * drawer height (viewport minus top inset). + * A single snap point. + * + * - `number` ≤ 1 → fraction of available drawer height (e.g. `0.5` is half) + * - `number` > 1 → pixel height (e.g. `480` is 480px) + * - `'auto'` → measured intrinsic content height (live, via `ResizeObserver`) + * - `'full'` → full available drawer height (viewport minus top inset) + * + * Use as elements of `Drawer`’s `snapPoints` array, or as the type of a + * `defaultSnapPoint` / `activeSnapPoint` value. */ -export type SnapPointValue = number | 'auto' | 'full' - -export type DrawerSizingPreset = - (typeof DRAWER_SIZING)[keyof typeof DRAWER_SIZING] - -export type DrawerSizing = DrawerSizingPreset | SnapPointValue[] +export type SnapPoint = number | 'auto' | 'full' export interface DrawerProps { - open: boolean - onOpenChange: (open: boolean) => void - /** `DRAWER_SIZING.AUTO` = content-sized (default), `FULL` = 100% height, or explicit snap array */ - sizing?: DrawerSizing - /** Which snap to open to. Ignored for `DRAWER_SIZING.AUTO` / `FULL` */ - defaultSnapPoint?: SnapPointValue - /** Controlled snap point (raw value, same encoding as sizing array) */ - activeSnapPoint?: SnapPointValue + /** + * Controlled open state. Pair with `onOpenChange` for fully controlled mode. + * Omit (and use `defaultOpen`) for uncontrolled mode. + */ + open?: boolean + /** + * Called when the drawer requests an open/close transition. Required in + * controlled mode; optional in uncontrolled mode. + */ + onOpenChange?: (open: boolean) => void + /** + * Initial open state for uncontrolled mode. Ignored when `open` is provided. + */ + defaultOpen?: boolean + /** + * Snap stops, evaluated bottom-to-top after sorting by resolved pixel height. + * Defaults to `[‘auto’]` — the drawer height follows the intrinsic content + * height. Pass `[‘full’]` for a single full-height drawer, or mix tokens + * with numeric stops, e.g. `[‘auto’, 480, ‘full’]`. + */ + snapPoints?: SnapPoint[] + /** + * Which snap to open to. When omitted, opens at the largest stop. + */ + defaultSnapPoint?: SnapPoint + /** + * Controlled active snap point. Pair with `onSnapPointChange` to observe + * snap changes initiated by this component; update this prop to drive the + * drawer to a specific stop from outside. + */ + activeSnapPoint?: SnapPoint + /** + * Called when the drawer initiates a snap change — on drag end, ref control + * calls (`snapTo`, `expand`, `collapse`), or `defaultSnapPoint` resolution on + * open. The resolved index is passed alongside the raw `SnapPoint` value for + * callers that need it. + * + * **Not** called for parent-driven updates: when the parent sets + * `activeSnapPoint` directly the drawer animates to that stop but does not + * echo the value back via this callback — the parent is already the source + * of truth and echoing would risk feedback loops. + */ + onSnapPointChange?: (point: SnapPoint, index: number) => void /** Drag below lowest snap dismisses the drawer (default true) */ dismissible?: boolean /** Show overlay + lock body scroll (default true) */ @@ -46,6 +78,35 @@ export interface DrawerProps { * or manage focus yourself. */ focusTrap?: boolean + /** + * When `true`, only a `Drawer.Handle` element can initiate a drag — touches + * on the rest of the content area are ignored. Useful when the content area + * contains its own gestures (e.g. a map or canvas). + */ + handleOnly?: boolean + /** + * Index into `snapPoints` at which the overlay becomes fully opaque (0-based, + * evaluated against the px-sorted snap array). Below that snap the overlay + * fades in proportionally as the drawer rises. Omit for the default + * all-or-nothing overlay that fades over the open animation. + * + * @example + * // snapPoints={[‘auto’, ‘full’]} — no overlay at ‘auto’, full overlay at ‘full’ + * fadeFromIndex={1} + */ + fadeFromIndex?: number + /** + * Disables velocity-based snap-skipping: with a fast swipe the drawer only + * moves one snap stop at a time rather than flying past intermediate stops. + * Position-based snapping (slow drag, nearest-stop) is unaffected. + */ + snapToSequentialPoint?: boolean + /** + * Set to `true` when this drawer is rendered inside another drawer. Stops + * pointer events from bubbling to the outer drawer’s drag handlers so the + * two drawers do not fight over the same gesture. + */ + nested?: boolean /** * Accessible name when you do not pass `title` (e.g. a short label for screen * readers). Prefer `title` (or a visible title in content) for modal drawers. @@ -82,7 +143,6 @@ export interface DrawerProps { */ slots?: DrawerSlots - onSnapPointChange?: (snapPoint: SnapPointValue, index: number) => void onDragStart?: ( event: MouseEvent | TouchEvent | PointerEvent, info: DragInfo, @@ -96,16 +156,16 @@ export interface DrawerProps { info: DragEndInfo, ) => void onAnimationStart?: (from: number, to: number) => void - onAnimationComplete?: (snapPoint: SnapPointValue) => void + onAnimationComplete?: (snapPoint: SnapPoint) => void onViewportChange?: (viewport: ViewportInfo) => void } export interface DrawerRef { - snapTo: (point: SnapPointValue) => void + snapTo: (point: SnapPoint) => void expand: () => void collapse: () => void dismiss: () => void - getActiveSnapPoint: () => SnapPointValue | null + getActiveSnapPoint: () => SnapPoint | null getHeight: () => number } @@ -116,7 +176,7 @@ export interface DragInfo { } export interface DragEndInfo extends DragInfo { - targetSnapPoint: SnapPointValue + targetSnapPoint: SnapPoint } export interface ViewportInfo {