Skip to content
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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
],
"packageManager": "bun@1.1.42",
"scripts": {
"playground": "vite --config playground/vite.config.ts",
"playground:build": "vite build --config playground/vite.config.ts",
"playground:preview": "vite preview --config playground/vite.config.ts",
"playground": "vite --config playground/vite.config.mts",
"playground:build": "vite build --config playground/vite.config.mts",
"playground:preview": "vite preview --config playground/vite.config.mts",
"build": "tsup",
"dev": "tsup --watch",
"lint": "biome check .",
Expand Down
111 changes: 110 additions & 1 deletion playground/src/PlaygroundApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type ScenarioId =
| 'imperative'
| 'controlledSnap'
| 'autoLoading'
| 'snapsLoadingTallerThanContent'

type LogLine = { t: number; text: string }

Expand Down Expand Up @@ -353,9 +354,103 @@ function AutoLoadingDemo({
)
}

/**
* Mixed sizing: `'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.
*
* Verifies:
* 1. `'auto'` slot follows the live `ResizeObserver` measurement (skeleton
* → 4 paragraphs grows the lowest stop, with the panel docked there).
* 2. Dragging up to the `'full'` stop sits the panel well above content
* height without the slow 1px-per-frame upward drift.
* 3. Swapping content while sitting at a non-auto snap does not jolt the
* drawer off that stop.
*/
function SnapsLoadingTallerThanContentDemo({
open,
onOpenChange,
onLog,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onLog: (line: string) => void
}) {
const [loading, setLoading] = useState(true)

useEffect(() => {
if (!open) return
setLoading(true)
const t = window.setTimeout(() => {
setLoading(false)
onLog('simulated fetch done — content swapped to taller block')
}, 1500)
return () => {
clearTimeout(t)
}
}, [open, onLog])

return (
<Drawer
key="snaps-loading-taller-than-content"
open={open}
onOpenChange={onOpenChange}
// First stop is content-fit ('auto'), second is a fixed pixel value, and
// 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]}
defaultSnapPoint={480}
title="Mixed sizing: AUTO + pixel + FULL"
onSnapPointChange={(p, i) => onLog(`onSnapPointChange: ${p} i=${i}`)}
onAnimationComplete={(p) =>
onLog(`onAnimationComplete: snap ${String(p)}`)
}
>
<Drawer.Content>
<Drawer.Handle />
<div className="px-4 pb-5 pt-0">
<h3 className="mb-2 text-sm font-medium text-zinc-800">
sizing=[AUTO, 480, FULL] · skeleton, then taller
</h3>
<p className="mb-3 text-xs text-zinc-500">
Default opens at 480px. Drag down to land on the AUTO slot
(content-fit) — its height grows when the fetch completes. Drag up
to FULL — panel should land cleanly with no slow upward drift.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</p>
{loading ? (
<div
className="space-y-2"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="h-3 w-2/3 max-w-sm animate-pulse rounded bg-zinc-200" />
<div className="h-3 w-1/2 max-w-xs animate-pulse rounded bg-zinc-200" />
<div className="h-3 w-3/5 max-w-sm animate-pulse rounded bg-zinc-200" />
</div>
) : (
<div className="space-y-3">
{(['s1', 's2', 's3', 's4'] as const).map((id, i) => (
<p key={id} className="text-sm leading-relaxed text-zinc-600">
Loaded section {i + 1} — content is now taller. The AUTO slot
tracks this height; the 480 and FULL stops do not.
</p>
))}
</div>
)}
</div>
</Drawer.Content>
</Drawer>
)
}

type StandardScenario = Exclude<
ScenarioId,
'imperative' | 'controlledSnap' | 'autoLoading'
| 'imperative'
| 'controlledSnap'
| 'autoLoading'
| 'snapsLoadingTallerThanContent'
>

function getScenarioDrawer(
Expand Down Expand Up @@ -560,6 +655,15 @@ export function PlaygroundApp() {
/>
)
}
if (scenario === 'snapsLoadingTallerThanContent') {
return (
<SnapsLoadingTallerThanContentDemo
open={open}
onOpenChange={handleOpenChange}
onLog={pushLog}
/>
)
}
const { drawer: d, children } = getScenarioDrawer(scenario, () =>
handleOpenChange(false),
)
Expand Down Expand Up @@ -617,6 +721,11 @@ export function PlaygroundApp() {
description="Two-line skeleton for ~1.5s, then more copy; sheet height should follow."
onOpen={() => openScenario('autoLoading')}
/>
<Panel
title="Mixed: AUTO + pixel + FULL snaps"
description="sizing=[AUTO, 480, FULL] with a skeleton→taller content swap. AUTO slot tracks measured content; 480/FULL stay above it."
onOpen={() => openScenario('snapsLoadingTallerThanContent')}
/>
<Panel
title="FULL"
description="Single snap at available height; scroll inside if content is tall."
Expand Down
File renamed without changes.
96 changes: 87 additions & 9 deletions src/components/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,39 @@ const DrawerRoot = forwardRef<DrawerRef, DrawerProps>(
onViewportChange,
})

const { snapHeights, defaultIndex, resolveSnapToIndex, indexToRawValue } =
useDrawerSnap({
sizing,
viewportHeight: viewport.height || availableHeight,
topInsetPx,
defaultSnapPoint,
contentMeasureRef: measureRef,
measureAttachGeneration,
})
const {
snapHeights,
rawSnapValues,
defaultIndex,
resolveSnapToIndex,
indexToRawValue,
} = useDrawerSnap({
sizing,
viewportHeight: viewport.height || availableHeight,
topInsetPx,
defaultSnapPoint,
contentMeasureRef: measureRef,
measureAttachGeneration,
})

const [snapIndex, setSnapIndex] = useState(defaultIndex)
// `snapIndex` is a numeric index into the px-sorted heights, but its
// *meaning* is the raw stop the user picked (e.g. `'auto'`, `480`,
// `'full'`). When 'auto' grows or shrinks the array re-sorts, and that
// same numeric index can suddenly point at a different raw stop. The
// remap logic below preserves logical identity across resorts.
//
// We snapshot the previous `rawSnapValues` array (and the snapIndex it
// was associated with) instead of mirroring the active raw value into a
// separate ref. Mirroring would lose the old identity: any code path
// that recomputes `indexToRawValue` against the *new* array (e.g. when
// 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<readonly SnapPointValue[] | null>(
null,
)
const prevSnapIndexRef = useRef<number>(defaultIndex)
const heightMv = useMotionValue(0)
const dragHeightStartRef = useRef(0)

Expand Down Expand Up @@ -251,9 +273,16 @@ const DrawerRoot = forwardRef<DrawerRef, DrawerProps>(

const introStartedRef = useRef(false)
const [resnapReady, setResnapReady] = useState(false)
// Tracks the height the resnap effect last kicked off an animation toward.
// Used to avoid restarting the spring every frame when AUTO measurements
// wobble by a pixel or two while the panel is still animating — a
// continually re-targeted spring manifests as the drawer creeping upward
// 1px at a time.
const lastResnapTargetRef = useRef<number | null>(null)
const resetDrawerMotionAfterExit = useCallback(() => {
heightMv.set(0)
updateProgress(0)
lastResnapTargetRef.current = null
}, [heightMv, updateProgress])

useEffect(() => {
Expand Down Expand Up @@ -304,6 +333,18 @@ const DrawerRoot = forwardRef<DrawerRef, DrawerProps>(
const targetH = snapHeights[targetIdx] ?? minSnap

if (Math.abs(heightMv.get() - targetH) < 2) return
// Already animating toward (effectively) this same target — let the
// current spring finish instead of restarting it with a near-identical
// goal. Without this, a measurement that wobbles by ~1px per frame
// re-fires `animate()` continuously and the spring never gets to
// overshoot/settle, producing a slow pixel-by-pixel drift.
if (
lastResnapTargetRef.current !== null &&
Math.abs(lastResnapTargetRef.current - targetH) < 2
) {
return
}
lastResnapTargetRef.current = targetH

animate(heightMv, targetH, {
...spring,
Expand All @@ -323,6 +364,43 @@ const DrawerRoot = forwardRef<DrawerRef, DrawerProps>(
resnapReady,
])

// Remap `snapIndex` by raw identity whenever the resolved raw-values
// array reorders. Without this, when 'auto' grows past a fixed stop and
// the px-sorted arrays re-sort, the drawer silently switches stops just
// because the same numeric index now refers to a different raw value.
//
// Identity is read from the *previous* rawSnapValues snapshot at the
// *previous* snapIndex — not from the new array — so reordering is
// detected correctly. Three transitions to consider:
// - Pure resort (rawSnapValues changed, snapIndex unchanged): look up
// the old raw in the new array and update snapIndex if it moved.
// - Pure index change (intro / drag-end / snapTo): user/system picked
// a new stop; do not remap, just record the new position.
// - Both changed simultaneously: prefer the explicit snapIndex change
// (the caller already chose against the new array contents).
useEffect(() => {
const prevArr = prevRawSnapValuesRef.current
const prevIdx = prevSnapIndexRef.current
const arrayChanged = prevArr !== null && prevArr !== rawSnapValues
const indexChanged = prevIdx !== snapIndex

if (arrayChanged && !indexChanged) {
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.
if (newIdx >= 0 && newIdx !== snapIndex) {
setSnapIndex(newIdx)
}
}
}

prevRawSnapValuesRef.current = rawSnapValues
prevSnapIndexRef.current = snapIndex
}, [rawSnapValues, snapIndex])

const lastActiveSnapRef = useRef<SnapPointValue | undefined>(undefined)
useEffect(() => {
if (!open) {
Expand Down
12 changes: 12 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ export const SNAP_POINT = {
THREE_QUARTERS: 0.75,
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

export const SPRING_CONFIG = {
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/useDrawerKeyboardSnapMobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { type RefObject, useCallback, useEffect, useRef } from 'react'

import { SNAP_POINT } from '../constants'
import type { DrawerRef, ViewportInfo } from '../types'
import type { DrawerRef, SnapPointValue, ViewportInfo } from '../types'

type Options = {
open: boolean
Expand All @@ -21,7 +21,10 @@ export function useDrawerKeyboardSnapMobile({
drawerRef,
}: Options) {
const wasKeyboardOpenRef = useRef(false)
const snapBeforeKeyboardRef = useRef<number | null>(null)
// SnapPointValue 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<SnapPointValue | null>(null)

useEffect(() => {
if (!open || !isMobile) {
Expand Down
45 changes: 45 additions & 0 deletions src/hooks/useDrawerSnap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,20 @@ describe('resolveSnapValueToPx', () => {
it('treats values > 1 as pixel heights', () => {
expect(resolveSnapValueToPx(320, 800)).toBe(320)
})

it("'full' resolves to the full available height", () => {
expect(resolveSnapValueToPx('full', 800)).toBe(800)
})

it("'auto' resolves to measured content height (capped at viewport)", () => {
expect(resolveSnapValueToPx('auto', 800, 320)).toBe(320)
expect(resolveSnapValueToPx('auto', 400, 900)).toBe(400)
})

it("'auto' resolves to 0 when no measurement is available yet", () => {
expect(resolveSnapValueToPx('auto', 800, null)).toBe(0)
expect(resolveSnapValueToPx('auto', 800)).toBe(0)
})
})

describe('resolveSizingToHeights', () => {
Expand Down Expand Up @@ -174,6 +188,37 @@ describe('resolveSizingToHeights', () => {
expect(heights).toEqual([100, 200, 300])
expect(rawValues).toEqual([0.25, 0.5, 0.75])
})

it("array with 'auto' substitutes the measured content height", () => {
const { heights, rawValues } = resolveSizingToHeights(
['auto', 480, 0.92],
1000,
280,
)
// 'auto' -> 280, 480 -> 480, 0.92 -> 920; sorted ascending
expect(heights).toEqual([280, 480, 920])
expect(rawValues).toEqual(['auto', 480, 0.92])
})

it("array with 'full' resolves to the full available height", () => {
const { heights, rawValues } = resolveSizingToHeights(['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)
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)
// 'auto' clamped to 600, 0.5 -> 300
expect(heights).toEqual([300, 600])
})
})

describe('heightToSnapRawValue', () => {
Expand Down
Loading
Loading