/** * `useDrawerSnapPoints` — resolves the configured snap points against the * measured popup / viewport geometry, derives offsets, and picks the * currently-active snap point. * * 1:1 port of Base UI React's * `packages/react/src/drawer/root/useDrawerSnapPoints.ts`. Solid deviations: * * - React's `useStableCallback` inside `measureViewportHeight` becomes a * plain function — Solid has no render cycle, callbacks close over the * current signals directly. * - `useIsoLayoutEffect` uses the split compute/apply form. * - Under happy-dom (test env), `offsetHeight` / `ResizeObserver` return 0 * / are absent, so `resolvedSnapPoints` is always `[]` and * `activeSnapPointOffset` is `null`. The test suite exercises the * structural contract; a real-browser pass is required for geometry * correctness. */ import { type Accessor, createMemo, createSignal, untrack } from "solid-js"; import { useDialogRootContext } from "../../dialog/dialog-root-context"; import { clamp } from "../../internals/slider-utils/clamp"; import { useIsoLayoutEffect } from "../../internals/use-iso-layout-effect"; import { type DrawerRootSnapPointChangeEventDetails, type DrawerSnapPoint, useDrawerRootContext, } from "./drawer-root-context"; export interface ResolvedDrawerSnapPoint { value: DrawerSnapPoint; height: number; offset: number; } function resolveSnapPointValue( snapPoint: DrawerSnapPoint, viewportHeight: number, rootFontSize: number, ): number | null { if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return null; if (typeof snapPoint === "number") { if (!Number.isFinite(snapPoint)) return null; if (snapPoint <= 1) return clamp(snapPoint, 0, 1) * viewportHeight; return snapPoint; } const trimmed = snapPoint.trim(); if (trimmed.endsWith("px")) { const value = Number.parseFloat(trimmed); return Number.isFinite(value) ? value : null; } if (trimmed.endsWith("rem")) { const value = Number.parseFloat(trimmed); return Number.isFinite(value) ? value * rootFontSize : null; } return null; } function findClosestSnapPoint( height: number, points: ResolvedDrawerSnapPoint[], ): ResolvedDrawerSnapPoint | null { let closest: ResolvedDrawerSnapPoint | null = null; let closestDistance = Infinity; for (const point of points) { const distance = Math.abs(point.height - height); if (distance < closestDistance) { closestDistance = distance; closest = point; } } return closest; } export interface UseDrawerSnapPointsReturn { snapPoints: Accessor; activeSnapPoint: Accessor; setActiveSnapPoint: ( snapPoint: DrawerSnapPoint | null, details?: DrawerRootSnapPointChangeEventDetails, ) => void; popupHeight: Accessor; viewportHeight: Accessor; resolvedSnapPoints: Accessor; activeSnapPointOffset: Accessor; } export function useDrawerSnapPoints(): UseDrawerSnapPointsReturn { const rootCtx = useDialogRootContext(); const drawer = useDrawerRootContext(); const viewportElement = rootCtx.viewportElement; const [viewportHeight, setViewportHeight] = createSignal(0, { ownedWrite: true, }); const [rootFontSize, setRootFontSize] = createSignal(16, { ownedWrite: true, }); // `measureViewportHeight` is invoked from the iso-layout-effect apply // scope (and from a `ResizeObserver` callback). The `viewportElement()` // snapshot read is intentional — wrap in `untrack` so it doesn't trip // `[STRICT_READ_UNTRACKED]` when called synchronously from apply. const measureViewportHeight = (): void => untrack(() => { const el = viewportElement(); const doc = el?.ownerDocument ?? document; const html = doc.documentElement; if (el) setViewportHeight(el.offsetHeight); else setViewportHeight(html.clientHeight); const fontSize = Number.parseFloat(getComputedStyle(html).fontSize); if (Number.isFinite(fontSize)) setRootFontSize(fontSize); }); useIsoLayoutEffect( () => viewportElement(), (el) => { measureViewportHeight(); if (!el || typeof ResizeObserver !== "function") return undefined; const resizeObserver = new ResizeObserver(measureViewportHeight); resizeObserver.observe(el); return () => { resizeObserver.disconnect(); }; }, ); const resolvedSnapPoints = createMemo(() => { const snapPoints = drawer.snapPoints(); const popupHeight = drawer.popupHeight(); const viewportH = viewportHeight(); const fontSize = rootFontSize(); if ( !snapPoints || snapPoints.length === 0 || viewportH <= 0 || popupHeight <= 0 ) return []; const maxHeight = Math.min(popupHeight, viewportH); if (!Number.isFinite(maxHeight) || maxHeight <= 0) return []; const resolved = snapPoints .map((value): ResolvedDrawerSnapPoint | null => { const resolvedHeight = resolveSnapPointValue( value, viewportH, fontSize, ); if (resolvedHeight === null || !Number.isFinite(resolvedHeight)) return null; const clampedHeight = clamp(resolvedHeight, 0, maxHeight); return { value, height: clampedHeight, offset: Math.max(0, popupHeight - clampedHeight), }; }) .filter((p): p is ResolvedDrawerSnapPoint => Boolean(p)); if (resolved.length <= 1) return resolved; const deduped: ResolvedDrawerSnapPoint[] = []; const seenHeights: number[] = []; for (let i = resolved.length - 1; i >= 0; i -= 1) { const p = resolved[i]; if (!p) continue; const dup = seenHeights.some((h) => Math.abs(h - p.height) <= 1); if (dup) continue; seenHeights.push(p.height); deduped.push(p); } deduped.reverse(); return deduped; }); const resolvedActiveSnapPoint = createMemo< ResolvedDrawerSnapPoint | undefined >(() => { const active = drawer.activeSnapPoint(); const points = resolvedSnapPoints(); if (active === undefined) return points[0]; if (active === null) return undefined; const exact = points.find((p) => Object.is(p.value, active)); if (exact) return exact; const popupHeight = drawer.popupHeight(); const viewportH = viewportHeight(); const maxHeight = Math.min(popupHeight, viewportH); const resolvedHeight = resolveSnapPointValue( active, viewportH, rootFontSize(), ); if (resolvedHeight === null || !Number.isFinite(resolvedHeight)) return undefined; const clampedHeight = clamp(resolvedHeight, 0, maxHeight); return findClosestSnapPoint(clampedHeight, points) ?? undefined; }); return { snapPoints: drawer.snapPoints, activeSnapPoint: drawer.activeSnapPoint, setActiveSnapPoint: drawer.setActiveSnapPoint, popupHeight: drawer.popupHeight, viewportHeight, resolvedSnapPoints, activeSnapPointOffset: () => resolvedActiveSnapPoint()?.offset ?? null, }; }