diff --git a/package.json b/package.json index 5a7c309..4e31a55 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/playground/src/PlaygroundApp.tsx b/playground/src/PlaygroundApp.tsx index 66772e7..5630fa6 100644 --- a/playground/src/PlaygroundApp.tsx +++ b/playground/src/PlaygroundApp.tsx @@ -25,6 +25,7 @@ type ScenarioId = | 'imperative' | 'controlledSnap' | 'autoLoading' + | 'snapsLoadingTallerThanContent' type LogLine = { t: number; text: string } @@ -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 ( + onLog(`onSnapPointChange: ${p} i=${i}`)} + onAnimationComplete={(p) => + onLog(`onAnimationComplete: snap ${String(p)}`) + } + > + + +
+

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

+

+ 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. +

+ {loading ? ( +
+
+
+
+
+ ) : ( +
+ {(['s1', 's2', 's3', 's4'] as const).map((id, i) => ( +

+ Loaded section {i + 1} — content is now taller. The AUTO slot + tracks this height; the 480 and FULL stops do not. +

+ ))} +
+ )} +
+ + + ) +} + type StandardScenario = Exclude< ScenarioId, - 'imperative' | 'controlledSnap' | 'autoLoading' + | 'imperative' + | 'controlledSnap' + | 'autoLoading' + | 'snapsLoadingTallerThanContent' > function getScenarioDrawer( @@ -560,6 +655,15 @@ export function PlaygroundApp() { /> ) } + if (scenario === 'snapsLoadingTallerThanContent') { + return ( + + ) + } const { drawer: d, children } = getScenarioDrawer(scenario, () => handleOpenChange(false), ) @@ -617,6 +721,11 @@ export function PlaygroundApp() { description="Two-line skeleton for ~1.5s, then more copy; sheet height should follow." onOpen={() => openScenario('autoLoading')} /> + openScenario('snapsLoadingTallerThanContent')} + /> ( 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( + null, + ) + const prevSnapIndexRef = useRef(defaultIndex) const heightMv = useMotionValue(0) const dragHeightStartRef = useRef(0) @@ -251,9 +273,16 @@ const DrawerRoot = forwardRef( 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(null) const resetDrawerMotionAfterExit = useCallback(() => { heightMv.set(0) updateProgress(0) + lastResnapTargetRef.current = null }, [heightMv, updateProgress]) useEffect(() => { @@ -304,6 +333,18 @@ const DrawerRoot = forwardRef( 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, @@ -323,6 +364,43 @@ const DrawerRoot = forwardRef( 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(undefined) useEffect(() => { if (!open) { diff --git a/src/constants.ts b/src/constants.ts index fbaa917..afa12ac 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 = { diff --git a/src/hooks/useDrawerKeyboardSnapMobile.ts b/src/hooks/useDrawerKeyboardSnapMobile.ts index 23eedc6..81b13db 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, ViewportInfo } from '../types' +import type { DrawerRef, SnapPointValue, ViewportInfo } from '../types' type Options = { open: boolean @@ -21,7 +21,10 @@ export function useDrawerKeyboardSnapMobile({ drawerRef, }: Options) { const wasKeyboardOpenRef = useRef(false) - const snapBeforeKeyboardRef = useRef(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(null) useEffect(() => { if (!open || !isMobile) { diff --git a/src/hooks/useDrawerSnap.test.ts b/src/hooks/useDrawerSnap.test.ts index 6e22649..37e7d01 100644 --- a/src/hooks/useDrawerSnap.test.ts +++ b/src/hooks/useDrawerSnap.test.ts @@ -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', () => { @@ -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', () => { diff --git a/src/hooks/useDrawerSnap.ts b/src/hooks/useDrawerSnap.ts index 0fc4342..c214ff7 100644 --- a/src/hooks/useDrawerSnap.ts +++ b/src/hooks/useDrawerSnap.ts @@ -22,7 +22,20 @@ const AUTO_FALLBACK_HEIGHT_PX = 0 export function resolveSnapValueToPx( value: SnapPointValue, availableHeight: number, + measuredAutoHeight?: number | null, ): number { + // String tokens describe height policies, not literal numbers: + // - 'auto' uses the live measured content height (capped at the viewport) + // - 'full' uses the full available drawer height + if (value === 'auto') { + return Math.min( + Math.max(0, availableHeight), + Math.max(0, Math.round(measuredAutoHeight ?? 0)), + ) + } + if (value === 'full') { + return Math.max(0, availableHeight) + } if (value <= 1) { return Math.max(0, Math.round(value * availableHeight)) } @@ -55,7 +68,7 @@ export function resolveSizingToHeights( const pairs = sizing.map((raw) => ({ raw, - px: resolveSnapValueToPx(raw, cap), + px: resolveSnapValueToPx(raw, cap, measuredAutoHeight), })) pairs.sort((a, b) => a.px - b.px) @@ -117,10 +130,14 @@ export function maxDescendantScrollOverflow(el: HTMLElement): number { * height (which `ResizeObserver`’s `contentRect` on the content root tracks). * Other direct children add **layout height plus** the max descendant * `(scrollHeight − clientHeight)` under that child so unmarked scroll areas - * still expand AUTO. For non-scroll children we also take the maximum with - * `scrollHeight` / bounding height so a flex child is not read as 0 when the - * panel height is still animating (offsetHeight collapsed while the sheet is - * short). Prefer `Drawer.Handle` and `Drawer.Scrollable` as direct children of + * still expand AUTO. For non-scroll children, when `offsetHeight` collapses to + * 0 (a flex child while the panel height is still animating short), fall back + * to `scrollHeight` so AUTO does not under-measure. We deliberately do NOT use + * `getBoundingClientRect().height` as a floor: for stretchy children that + * fill the panel, that value tracks the panel height and creates a feedback + * loop where the measured height grows in lockstep with the animating panel — + * the user-visible symptom is the drawer sliding upward pixel-by-pixel. + * Prefer `Drawer.Handle` and `Drawer.Scrollable` as direct children of * `Drawer.Content` when possible. */ export function measureIntrinsicAutoHeight(root: HTMLElement): number { @@ -130,15 +147,34 @@ export function measureIntrinsicAutoHeight(root: HTMLElement): number { if (!(child instanceof HTMLElement)) continue if (child.hasAttribute('data-drawer-scroll')) { sawScrollRegion = true - sum += child.scrollHeight + // When content overflows the scroll container, `scrollHeight` reports + // the natural overflow content size — exactly what AUTO wants in order + // to expand the drawer to fit. When content fits, however, `scrollHeight` + // equals `clientHeight`, which tracks the (possibly animating) container + // height. Using it directly there feeds the panel's height back into + // the measurement and produces a slow upward drift. In the no-overflow + // case sum the direct children's own offsetHeights so the measurement + // reflects intrinsic content rather than the stretched container. + const scrollOverflow = child.scrollHeight - child.clientHeight + if (scrollOverflow > 0) { + sum += child.scrollHeight + } else { + let inner = 0 + for (const grand of child.children) { + if (grand instanceof HTMLElement) inner += grand.offsetHeight + } + sum += inner + } } else { const withDescendantOverflow = child.offsetHeight + maxDescendantScrollOverflow(child) - const fromIntrinsic = Math.max( - child.scrollHeight, - child.getBoundingClientRect().height, - ) - sum += Math.max(withDescendantOverflow, fromIntrinsic) + // Only use scrollHeight as a fallback when the layout collapsed the + // child to 0 (flex item during the intro animation). Otherwise trust + // `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 + sum += contribution } } if (sawScrollRegion || sum > 0) { @@ -342,8 +378,15 @@ 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')) + useEffect(() => { - if (sizing !== DRAWER_SIZING.AUTO) return + if (!needsAutoMeasurement) return const el = contentMeasureRef.current if (!el) { setMeasuredAutoHeight(null) @@ -351,9 +394,15 @@ export function useDrawerSnap({ } return bindAutoMeasureObservers(el, (h) => { - setMeasuredAutoHeight(h) + // Hysteresis: ignore sub-pixel jitter (≤1px) so transient layout noise + // during the open animation doesn't churn `snapHeights` and re-target + // the resnap spring every frame. + setMeasuredAutoHeight((prev) => { + if (prev !== null && Math.abs(prev - h) <= 1) return prev + return h + }) }) - }, [contentMeasureRef, sizing, measureAttachGeneration]) + }, [contentMeasureRef, needsAutoMeasurement, measureAttachGeneration]) const { heights, rawValues } = useMemo( () => resolveSizingToHeights(sizing, availableHeight, measuredAutoHeight), @@ -363,9 +412,13 @@ export function useDrawerSnap({ const defaultIndex = useMemo(() => { if (heights.length === 0) return 0 if (defaultSnapPoint === undefined) return heights.length - 1 - const target = resolveSnapValueToPx(defaultSnapPoint, availableHeight) + const target = resolveSnapValueToPx( + defaultSnapPoint, + availableHeight, + measuredAutoHeight, + ) return nearestHeightIndex(target, heights) - }, [heights, defaultSnapPoint, availableHeight]) + }, [heights, defaultSnapPoint, availableHeight, measuredAutoHeight]) return { availableHeight, @@ -374,10 +427,14 @@ export function useDrawerSnap({ defaultIndex, resolveSnapToIndex: useCallback( (point: SnapPointValue) => { - const target = resolveSnapValueToPx(point, availableHeight) + const target = resolveSnapValueToPx( + point, + availableHeight, + measuredAutoHeight, + ) return nearestHeightIndex(target, heights) }, - [availableHeight, heights], + [availableHeight, heights, measuredAutoHeight], ), indexToRawValue: useCallback( (index: number): SnapPointValue | null => { diff --git a/src/types.ts b/src/types.ts index 548372d..3a63714 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,8 +13,14 @@ export type DrawerSlots = { handleIndicatorClassName?: string } -/** A snap point: decimal 0–1 (fraction of available height) or px when value > 1 */ -export type SnapPointValue = number +/** + * 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). + */ +export type SnapPointValue = number | 'auto' | 'full' export type DrawerSizingPreset = (typeof DRAWER_SIZING)[keyof typeof DRAWER_SIZING] diff --git a/src/utils.ts b/src/utils.ts index 952d02b..b51a835 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,28 +2,44 @@ import { type ClassValue, clsx } from 'clsx' let twMerge: ((input: string) => string) | undefined let isTwMergeInitialized = false +let twMergeInitPromise: Promise | undefined -export async function initTailwindMerge(): Promise { - if (isTwMergeInitialized) { - return - } - - try { - const module = await import('tailwind-merge') - twMerge = module?.twMerge || module?.default?.twMerge - } catch { - // tailwind-merge is not available, use fallback - twMerge = undefined - } +/** + * Lazily resolve `tailwind-merge` (an optional peer dep). Safe to call many + * times — the import is shared. Once it resolves, future `cn()` calls dedupe + * Tailwind classes; until then they fall back to plain `clsx`. + */ +function ensureTwMergeInit(): Promise { + if (twMergeInitPromise) return twMergeInitPromise + twMergeInitPromise = import('tailwind-merge') + .then((module) => { + twMerge = module?.twMerge ?? module?.default?.twMerge + }) + .catch(() => { + // tailwind-merge is not installed — keep `twMerge` undefined and use + // the clsx-only fallback in `cn`. + twMerge = undefined + }) + .finally(() => { + isTwMergeInitialized = true + }) + return twMergeInitPromise +} - isTwMergeInitialized = true +/** + * Opt-in: pre-warm tailwind-merge so the very first render can dedupe classes + * synchronously. Optional — `cn()` works without it (the import kicks off + * lazily on first use; renders before it resolves use the clsx-only fallback). + */ +export async function initTailwindMerge(): Promise { + await ensureTwMergeInit() } export function cn(...inputs: ClassValue[]) { + // Kick off the dynamic import on first use; do not block rendering on it. + // Subsequent renders pick up `twMerge` once the promise has resolved. if (!isTwMergeInitialized) { - throw new Error( - 'initTailwindMerge() must be awaited before using cn(). Call await initTailwindMerge() during initialization.', - ) + void ensureTwMergeInit() } const classes = clsx(inputs)