tera-system-ui 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/components/bottom-sheet/BottomSheet.recipe.d.ts +20 -0
  2. package/dist/components/bottom-sheet/BottomSheet.svelte +113 -4
  3. package/dist/components/bottom-sheet/bottomSheetStore.svelte.d.ts +28 -1
  4. package/dist/components/bottom-sheet/bottomSheetStore.svelte.js +60 -39
  5. package/dist/components/combobox/Combobox.svelte +6 -0
  6. package/dist/components/date-range-picker/DateRangePicker.svelte +14 -1
  7. package/dist/components/dialog/Dialog.svelte +15 -2
  8. package/dist/components/drawer/Drawer.svelte +23 -1
  9. package/dist/components/dropdown-menu/components/DropdownMenu.svelte +7 -0
  10. package/dist/components/popover/Popover.svelte +8 -1
  11. package/dist/components/select/Select.svelte +14 -2
  12. package/dist/components/toast/ToastContainer.svelte +8 -1
  13. package/dist/components/tooltip/Tooltip.recipe.js +6 -1
  14. package/dist/hooks/index.d.ts +4 -0
  15. package/dist/hooks/index.js +3 -0
  16. package/dist/hooks/overlayLayer.svelte.d.ts +43 -0
  17. package/dist/hooks/overlayLayer.svelte.js +74 -0
  18. package/dist/hooks/overlayStack.svelte.d.ts +59 -0
  19. package/dist/hooks/overlayStack.svelte.js +107 -0
  20. package/dist/llms/bottom-sheet.md +2 -0
  21. package/dist/tokens/index.d.ts +35 -0
  22. package/dist/tokens/index.js +34 -0
  23. package/package.json +6 -1
  24. package/registry/bottom-sheet.json +50 -50
  25. package/registry/combobox.json +2 -2
  26. package/registry/date-range-picker.json +2 -2
  27. package/registry/dialog.json +1 -1
  28. package/registry/drawer.json +2 -2
  29. package/registry/dropdown-menu.json +2 -2
  30. package/registry/popover.json +2 -2
  31. package/registry/select.json +2 -2
  32. package/registry/toast.json +1 -1
  33. package/registry/tooltip.json +1 -1
@@ -1,50 +1,50 @@
1
- {
2
- "$schema": "https://tera-system-ui/registry-item.schema.json",
3
- "name": "bottom-sheet",
4
- "type": "registry:component",
5
- "description": "Draggable iOS-style bottom sheet with snap points, container-scale effect, and stacking.",
6
- "recipe": "src/lib/components/bottom-sheet/BottomSheet.recipe.ts",
7
- "files": [
8
- {
9
- "path": "src/lib/components/bottom-sheet/BottomSheet.recipe.ts",
10
- "target": "BottomSheet.recipe.ts",
11
- "type": "recipe",
12
- "content": "import {tv, type VariantProps} from \"tailwind-variants\";\nimport type {Snippet} from \"svelte\";\n\n/**\n * BottomSheet recipe — the canonical styled layer for the draggable sheet.\n *\n * An iOS-style bottom sheet with snap points, gesture dragging, container-scale\n * effect, and stacking (custom behavior — not bits-ui). The recipe is slotted by\n * role — backdrop / sheet / handle / handleBar / header / closeButton / content —\n * so an app can restyle any part independently. Only the *themeable, static*\n * surface lives here; the per-frame transforms (translate / scale / opacity) are\n * driven imperatively from the component and the runtime transition duration is\n * passed through the `--transition-duration` CSS var.\n *\n * The `constrained` variant switches fixed → absolute positioning when the sheet\n * is bounded inside a container; `dragging` carries the grabbing cursor.\n *\n * Primary customization surface: extend with `tv({ extend: bottomSheetRecipe, ... })`\n * in typed code. Lighter rung: the per-component CSS var (--tera-bottom-sheet-radius)\n * retunes the top corner radius with no code change; it defaults to the prior\n * literal value inline.\n */\nexport const bottomSheetRecipe = tv({\n slots: {\n backdrop: 'fixed inset-0 bg-black opacity-0 touch-none [will-change:opacity] [-webkit-tap-highlight-color:transparent] transition-opacity duration-[var(--transition-duration,0.35s)] ease-out',\n sheet: 'fixed bottom-0 left-0 right-0 flex flex-col overflow-visible bg-surface-raised rounded-t-[var(--tera-bottom-sheet-radius,12px)] [will-change:translate] [translate:0_100%] [transform:translateZ(0)] [backface-visibility:hidden] transition-[translate] duration-[var(--transition-duration,0.35s)] ease-[cubic-bezier(0.2,0.9,0.36,1)]',\n handle: 'flex shrink-0 items-center justify-center px-4 pt-2 pb-1 cursor-grab touch-none active:cursor-grabbing',\n handleBar: 'h-[5px] w-9 rounded-[2.5px] bg-text-tertiary',\n header: 'relative shrink-0 cursor-grab touch-none select-none border-b border-border-default px-4 pt-1 pb-3 active:cursor-grabbing',\n closeButton: 'absolute right-2 top-[calc(50%-4px)] flex h-8 w-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 text-text-tertiary [touch-action:manipulation] [-webkit-tap-highlight-color:transparent] transition-colors hover:bg-surface-hover hover:text-text-primary active:bg-surface-hover',\n content: 'flex min-h-0 flex-1 flex-col overflow-hidden [touch-action:pan-y]',\n },\n variants: {\n // Sheet bounded inside a container (vs. the viewport): switch fixed → absolute.\n constrained: {\n true: {backdrop: 'absolute', sheet: 'absolute'},\n false: {},\n },\n // Active drag: grabbing cursor + suppress selection.\n dragging: {\n true: {sheet: 'cursor-grabbing select-none'},\n false: {},\n },\n },\n defaultVariants: {\n constrained: false,\n dragging: false,\n },\n});\n\nexport type BottomSheetRecipeProps = VariantProps<typeof bottomSheetRecipe>;\n\nexport interface BottomSheetProps {\n /** Unique identifier for this sheet (drives stacking/z-index registry). */\n id: string;\n /** Whether the sheet is open. Bindable. */\n open?: boolean;\n /** Snap positions as viewport fractions (0–1), e.g. [0.25, 0.5, 0.9]. */\n snapPoints?: number[];\n /** Index into `snapPoints` to settle at when opening. */\n defaultSnapPoint?: number;\n /** Container element to constrain the sheet within (constrained mode). */\n containerRef?: HTMLElement | null;\n /** Element to apply the background scale effect to (defaults to containerRef). */\n scaleTargetRef?: HTMLElement | null;\n /** Constrain the sheet inside `containerRef` (absolute positioning). */\n constrainToContainer?: boolean;\n /** Scale/round the background target as the sheet opens (iOS effect). */\n scaleBackground?: boolean;\n /** Allow dragging down past the lowest snap point to close. */\n dragToClose?: boolean;\n /** Close when the backdrop is tapped. */\n closeOnBackdrop?: boolean;\n /** Show a close (X) button in the header (requires the `header` snippet). */\n showCloseButton?: boolean;\n /** Called after the sheet finishes closing. */\n onClose?: () => void;\n /** Called whenever the open state changes. */\n onOpenChange?: (open: boolean) => void;\n /** Main body content. */\n children?: Snippet;\n /** Header content; renders the header bar when set. */\n header?: Snippet;\n}\n"
13
- },
14
- {
15
- "path": "src/lib/components/bottom-sheet/BottomSheet.svelte",
16
- "target": "BottomSheet.svelte",
17
- "type": "svelte",
18
- "content": "<script lang=\"ts\">\n import {onDestroy, onMount} from 'svelte';\n import {bottomSheetStore} from './bottomSheetStore.svelte.js';\n import {bottomSheetRecipe, type BottomSheetProps} from './BottomSheet.recipe';\n import {IconX} from \"$lib/internal/icons\";\n\n // ============================================\n // CONFIGURABLE CONSTANTS\n // Adjust these values to customize the bottom sheet behavior\n // ============================================\n\n const CONFIG = {\n // Scale effect for background container\n scale: {\n factor: 0.06, // How much to scale down (6%)\n borderRadius: 16, // Border radius when scaled (px)\n translateY: 20, // Vertical offset when scaled (px)\n origin: 'top center' // Transform origin\n },\n\n // Backdrop\n backdrop: {\n maxOpacity: 0.5 // Maximum opacity when fully open\n },\n\n // Spring physics for open / close / settle. iOS-style motion is spring-based,\n // not fixed-duration easing: `response` is the approximate settle time (s) and\n // `damping` is the damping ratio (1 = critical/no bounce, <1 adds a little life).\n // Duration is emergent, and — crucially — the spring is seeded with the finger's\n // release velocity so the motion continues seamlessly instead of restarting.\n // Tuned snappy to match native iOS; `close` is underdamped and finalizes the\n // instant it reaches offscreen (see animationFrame) so there is no lingering tail.\n animation: {\n open: {response: 0.4, damping: 0.86}, // present — quick, no bounce\n close: {response: 0.26, damping: 0.85}, // dismiss — fast, finalizes on arrival\n snap: {response: 0.32, damping: 0.84}, // moving between snap points\n restDistance: 0.5, // px from target to consider a visible spring settled\n restVelocity: 40, // px/s below which a visible spring is considered at rest\n maxFrameDt: 0.064 // clamp per-frame dt after tab-switch stalls (s)\n },\n\n // Gesture handling\n gesture: {\n velocityInfluence: 0.01, // How much velocity affects snap selection\n closeVelocityThreshold: 0.35, // Velocity to trigger close (px/ms)\n closeProgressThreshold: 0.35, // Progress to trigger close (0-1)\n maxLockedDrag: 60, // Max drag when dragToClose disabled (px)\n lockedDragResistance: 0.3, // Resistance when dragToClose disabled\n // Two-phase gesture recognition for interactive elements (iOS-style)\n dragActivationThreshold: 10, // Vertical px to commit to drag from interactive element\n horizontalDeadZone: 1.2, // If abs(dx)/abs(dy) > this, abort drag (let native horizontal win)\n tapDistanceThreshold: 6 // Max total movement (px) to still count as a tap\n }\n } as const;\n\n // ============================================\n // COMPONENT PROPS\n // ============================================\n\n let {\n id,\n open = $bindable(false),\n snapPoints = [0.9],\n defaultSnapPoint = 0,\n containerRef = null,\n scaleTargetRef = null,\n constrainToContainer = false,\n scaleBackground = true,\n dragToClose = true,\n closeOnBackdrop = true,\n showCloseButton = false,\n onClose,\n onOpenChange,\n children,\n header\n }: BottomSheetProps = $props();\n\n // ============================================\n // INTERNAL STATE\n // ============================================\n\n // DOM references\n let sheetRef = $state<HTMLElement | null>(null);\n let contentRef = $state<HTMLElement | null>(null);\n let backdropRef = $state<HTMLElement | null>(null);\n\n // Sheet state\n let isVisible = $state(false);\n let isClosing = $state(false);\n let isDragging = $state(false);\n // Plain (NOT $state): written on every animation frame and drag move, but never read\n // reactively (no template / $derived / $effect depends on it). Keeping it out of the\n // reactivity graph avoids ~120 signal writes/sec on high-refresh displays.\n let currentTranslateY = 0;\n let sheetHeight = $state(0);\n\n // Spring animation engine — a single interruptible rAF loop owns the sheet's\n // position. currentTranslateY always reflects the LIVE on-screen value (written\n // every frame), so grabbing the sheet mid-animation never jumps.\n let rafId: number | null = null;\n let animTarget = 0; // px, translateY the spring settles toward\n let animVelocity = 0; // px/s, live spring velocity (carries flicks)\n let springK = 0; // stiffness\n let springC = 0; // damping coefficient\n let animOnComplete: (() => void) | null = null;\n let animHideOnArrival = false; // close: finalize the moment the sheet is offscreen\n let lastFrameTime = 0;\n let scaleTargetPrimed = false; // static scale-target styles set once per open cycle\n\n // Drag tracking\n let dragState = {\n startY: 0,\n startX: 0,\n lastY: 0,\n currentY: 0,\n deltaY: 0,\n startSnapPoint: 0,\n activePointerId: null as number | null,\n isTouchActive: false,\n shouldPreventScroll: false,\n history: [] as { y: number; time: number }[],\n // Two-phase gesture recognition for interactive elements\n isPending: false, // Tracking started but not yet committed to drag\n isInteractiveStart: false, // Gesture began on an interactive element\n committed: false, // Gesture has been resolved as a drag\n inScrollable: false // Gesture began inside a [data-bottom-sheet-scroll] area\n };\n\n // Scroll state\n let scrollableElement: HTMLElement | null = null;\n let isScrolledToTop = true;\n\n // Backdrop dismiss: the pointerId of a press that STARTED on the backdrop. A genuine\n // dismiss is a full press+release on the backdrop; tracking the origin lets us ignore\n // the stray pointerup that lands on a freshly-mounted backdrop when a press begun\n // elsewhere (e.g. a long-press on a button that opened the sheet) is released over it.\n let backdropPressPointerId: number | null = null;\n\n // Dimensions\n let windowHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 800);\n let containerHeight = $state(0);\n let containerResizeObserver: ResizeObserver | null = null;\n let prevOpen: boolean | null = null;\n\n // ============================================\n // COMPUTED VALUES\n // ============================================\n\n const r = $derived(bottomSheetRecipe({constrained: constrainToContainer, dragging: isDragging}));\n\n const scaleTarget = $derived(scaleTargetRef ?? containerRef);\n const effectiveHeight = $derived(\n constrainToContainer && containerRef ? containerHeight : windowHeight\n );\n const maxSnapPoint = $derived(Math.max(...snapPoints));\n const maxSheetHeight = $derived(maxSnapPoint * effectiveHeight);\n const zIndex = $derived(bottomSheetStore.getZIndex(id));\n const currentSnapPoint = $derived(snapPoints[defaultSnapPoint] ?? 0.5);\n const allSnapPoints = $derived([0, ...snapPoints].sort((a, b) => a - b));\n\n // ============================================\n // LIFECYCLE\n // ============================================\n\n onMount(() => {\n bottomSheetStore.register(id, {snapPoints, defaultSnapPoint, closeOnBackdrop});\n window.addEventListener('resize', handleWindowResize);\n\n return () => {\n bottomSheetStore.unregister(id);\n window.removeEventListener('resize', handleWindowResize);\n };\n });\n\n onDestroy(() => {\n containerResizeObserver?.disconnect();\n cancelAnim();\n });\n\n // The spring loop drives every visual imperatively (translate / opacity / scale),\n // so keep the recipe's CSS transitions OFF on these elements — otherwise a CSS\n // transition would try to animate between each per-frame write and smear the motion.\n $effect(() => {\n if (sheetRef) sheetRef.style.transition = 'none';\n if (backdropRef) backdropRef.style.transition = 'none';\n });\n\n // Watch containerRef for constrained mode\n $effect(() => {\n if (!containerRef || !constrainToContainer) return;\n\n containerHeight = containerRef.clientHeight;\n containerResizeObserver?.disconnect();\n\n containerResizeObserver = new ResizeObserver(() => {\n containerHeight = containerRef!.clientHeight;\n if (isVisible && !isDragging && sheetRef) {\n sheetHeight = sheetRef.offsetHeight;\n }\n });\n containerResizeObserver.observe(containerRef);\n\n return () => {\n containerResizeObserver?.disconnect();\n containerResizeObserver = null;\n };\n });\n\n // Watch open prop changes\n $effect(() => {\n const shouldOpen = open;\n\n if (prevOpen === null) {\n prevOpen = shouldOpen;\n if (shouldOpen) doOpen();\n return;\n }\n\n if (shouldOpen && !prevOpen && !isVisible) {\n prevOpen = true;\n doOpen();\n } else if (!shouldOpen && prevOpen && isVisible && !isClosing) {\n prevOpen = false;\n doClose();\n }\n });\n\n // NOTE: The scrollable region ([data-bottom-sheet-scroll]) is resolved lazily\n // at gesture-start from the event target (see getScrollableFromTarget), NOT via\n // a one-shot querySelector here. A single querySelector at open-time cannot see\n // content that is mounted lazily AFTER the sheet opens (e.g. async-loaded lists),\n // which left scrollableElement null and broke drag/scroll arbitration inside it.\n\n // ============================================\n // CORE FUNCTIONS\n // ============================================\n\n function handleWindowResize() {\n if (isVisible && !isDragging) {\n windowHeight = window.innerHeight;\n if (sheetRef) sheetHeight = sheetRef.offsetHeight;\n }\n }\n\n function recalculateDimensions() {\n if (typeof window !== 'undefined') windowHeight = window.innerHeight;\n if (containerRef && constrainToContainer) containerHeight = containerRef.clientHeight;\n if (sheetRef) sheetHeight = sheetRef.offsetHeight;\n }\n\n // ============================================\n // SPRING ANIMATION ENGINE\n // ============================================\n // Settle, open and close all run through one interruptible rAF loop as damped\n // springs seeded with the finger's release velocity, so the motion CONTINUES from\n // the drag rather than restarting as a fresh CSS tween. The loop writes the live\n // position every frame, which is what makes catching the sheet mid-flight feel\n // native — there is no destination-vs-live mismatch to jump across.\n\n const prefersReducedMotion = () =>\n typeof window !== 'undefined' &&\n !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;\n\n /** Convert (response, dampingRatio) to spring stiffness/damping (unit mass). */\n function springConstants(response: number, damping: number) {\n const omega = (2 * Math.PI) / response; // natural angular frequency\n return {k: omega * omega, c: 2 * damping * omega};\n }\n\n /** Write the whole visual state for a given translateY (transform + backdrop + scale + progress). */\n function renderPosition(translateY: number) {\n if (sheetRef) sheetRef.style.translate = `0 ${translateY}px`;\n const rawProgress = (sheetHeight - translateY) / effectiveHeight;\n const progress = Math.max(0, Math.min(maxSnapPoint, rawProgress));\n updateBackdrop(progress);\n updateScaleTarget(progress);\n bottomSheetStore.updateProgress(id, Math.max(0, Math.min(1, rawProgress)));\n }\n\n function cancelAnim() {\n if (rafId !== null) {\n cancelAnimationFrame(rafId);\n rafId = null;\n }\n animOnComplete = null;\n }\n\n /** Stop any in-flight settle so a fresh gesture anchors to the true live position. */\n function interruptAnimation() {\n cancelAnim();\n isClosing = false;\n }\n\n /**\n * Spring toward a target translateY, optionally seeded with an initial velocity\n * (px/s — e.g. the finger's flick at release). Retargets a running loop in place,\n * so successive calls (or a mid-flight direction change) stay continuous.\n */\n function animateTo(\n target: number,\n spring: {response: number; damping: number},\n initialVelocity = 0,\n onComplete?: () => void,\n hideOnArrival = false\n ) {\n animTarget = target;\n animOnComplete = onComplete ?? null;\n animHideOnArrival = hideOnArrival;\n const {k, c} = springConstants(spring.response, spring.damping);\n springK = k;\n springC = c;\n\n // Respect reduced-motion: jump straight to the target, no spring.\n if (prefersReducedMotion()) {\n cancelAnim();\n animVelocity = 0;\n currentTranslateY = target;\n renderPosition(target);\n onComplete?.();\n return;\n }\n\n animVelocity = initialVelocity;\n if (rafId === null) {\n lastFrameTime = performance.now();\n rafId = requestAnimationFrame(animationFrame);\n }\n }\n\n function animationFrame(now: number) {\n const {restDistance, restVelocity, maxFrameDt} = CONFIG.animation;\n let dt = (now - lastFrameTime) / 1000;\n lastFrameTime = now;\n if (dt > maxFrameDt) dt = maxFrameDt; // clamp after tab-switch stalls\n\n // Sub-step with semi-implicit (symplectic) Euler for stability at high stiffness.\n let x = currentTranslateY;\n let v = animVelocity;\n const steps = Math.max(1, Math.ceil(dt / 0.004));\n const h = dt / steps;\n for (let i = 0; i < steps; i++) {\n const a = -springK * (x - animTarget) - springC * v;\n v += a * h;\n x += v * h;\n }\n\n let settled = Math.abs(x - animTarget) < restDistance && Math.abs(v) < restVelocity;\n // Close/hide: finalize the instant the sheet reaches offscreen — on the way down,\n // BEFORE any underdamped rebound. This removes the long asymptotic spring \"tail\"\n // that used to keep isVisible/open true (and the backdrop swallowing taps) for a\n // few hundred ms after the sheet already looked closed.\n if (animHideOnArrival && x >= animTarget - 0.5) settled = true;\n if (settled) {\n x = animTarget;\n v = 0;\n }\n\n animVelocity = v;\n currentTranslateY = x;\n renderPosition(x);\n\n if (settled) {\n rafId = null;\n const cb = animOnComplete;\n animOnComplete = null;\n cb?.();\n } else {\n rafId = requestAnimationFrame(animationFrame);\n }\n }\n\n function doOpen() {\n // Refresh viewport/container dimensions BEFORE the sheet renders, so its\n // height (maxSheetHeight) is derived from current values. While the sheet is\n // closed, windowHeight is not tracked (handleWindowResize is gated on\n // isVisible), so a resize between opens would otherwise leave the first\n // render using a stale height — measured sheetHeight then mismatches the\n // fresh effectiveHeight, shifting the sheet off the bottom edge until the\n // next open. Doing this before isVisible keeps them in sync.\n if (typeof window !== 'undefined') windowHeight = window.innerHeight;\n if (containerRef && constrainToContainer) containerHeight = containerRef.clientHeight;\n\n isVisible = true;\n isClosing = false;\n backdropPressPointerId = null; // fresh open — ignore any stale backdrop press id\n bottomSheetStore.open(id);\n\n requestAnimationFrame(() => {\n // sheetRef is bound by this rAF tick.\n recalculateDimensions();\n // Start fully hidden (matches the sheet's initial CSS translate of 100%),\n // then spring up to the target snap point.\n currentTranslateY = sheetHeight;\n renderPosition(sheetHeight);\n const target = sheetHeight - currentSnapPoint * effectiveHeight;\n animateTo(target, CONFIG.animation.open, 0);\n });\n }\n\n function doClose() {\n if (isClosing) return;\n animateSheetClose(0);\n }\n\n /** Spring the sheet fully down and finalize. `initialVelocity` (px/s) carries a flick-to-dismiss. */\n function animateSheetClose(initialVelocity: number) {\n isClosing = true;\n animateTo(sheetHeight, CONFIG.animation.close, initialVelocity, () => {\n isVisible = false;\n isClosing = false;\n bottomSheetStore.close(id);\n prevOpen = false;\n open = false;\n resetScaleTarget();\n onClose?.();\n onOpenChange?.(false);\n }, true); // hideOnArrival — finalize as soon as it's offscreen, no spring tail\n }\n\n /**\n * Settle after a drag release: resolve the target snap point, then spring to it\n * carrying the finger's release velocity so the hand-off is seamless.\n */\n function settleFromRelease() {\n const nearestSnap = determineSnapPoint();\n const releaseVelocity = calculateVelocity() * 1000; // px/ms → px/s (down = positive)\n\n if (nearestSnap === 0 && dragToClose) {\n animateSheetClose(releaseVelocity);\n return;\n }\n\n const snap = nearestSnap > 0 ? nearestSnap : dragState.startSnapPoint;\n const target = sheetHeight - snap * effectiveHeight;\n animateTo(target, CONFIG.animation.snap, releaseVelocity, () => {\n if (!open) {\n prevOpen = true;\n open = true;\n onOpenChange?.(true);\n }\n });\n }\n\n // ============================================\n // VISUAL UPDATES\n // ============================================\n\n function updateBackdrop(progress: number) {\n if (!backdropRef) return;\n const opacity = Math.max(0, Math.min(CONFIG.backdrop.maxOpacity, progress * CONFIG.backdrop.maxOpacity));\n backdropRef.style.opacity = String(opacity);\n }\n\n function updateScaleTarget(progress: number) {\n if (!scaleTarget || !scaleBackground) return;\n\n // Set the STATIC props ONCE per open cycle. Re-writing transition / transformOrigin /\n // overflow / will-change every frame churned the compositor layer (will-change\n // especially) and, together with a per-frame border-radius repaint of this\n // full-screen element, blew the frame budget on 120Hz displays. Priming once fixes it.\n if (!scaleTargetPrimed) {\n scaleTarget.style.transition = 'none';\n scaleTarget.style.transformOrigin = CONFIG.scale.origin;\n scaleTarget.style.overflow = 'hidden';\n scaleTarget.style.willChange = 'scale, translate, border-radius';\n scaleTargetPrimed = true;\n }\n\n const p = Math.max(0, Math.min(1, progress));\n const {factor, borderRadius, translateY} = CONFIG.scale;\n\n // Per-frame: scale + translate are GPU-composited (cheap). Quantize border-radius to\n // whole px so it only actually repaints ~a dozen times across the gesture, not 120×/s.\n scaleTarget.style.scale = String(1 - factor * p);\n scaleTarget.style.translate = `0 ${(translateY * p).toFixed(2)}px`;\n scaleTarget.style.borderRadius = `${Math.round(borderRadius * p)}px`;\n }\n\n function resetScaleTarget() {\n scaleTargetPrimed = false;\n if (!scaleTarget || !scaleBackground) return;\n\n scaleTarget.style.scale = '';\n scaleTarget.style.translate = '';\n scaleTarget.style.borderRadius = '';\n scaleTarget.style.transformOrigin = '';\n scaleTarget.style.overflow = '';\n scaleTarget.style.transition = '';\n scaleTarget.style.willChange = '';\n }\n\n // ============================================\n // GESTURE UTILITIES\n // ============================================\n\n function pushHistory(y: number) {\n const now = performance.now();\n dragState.history = dragState.history.filter(p => now - p.time <= 100);\n dragState.history.push({y, time: now});\n }\n\n function clearHistory() {\n dragState.history = [];\n }\n\n function calculateVelocity(): number {\n const now = performance.now();\n const recent = dragState.history.filter(p => now - p.time <= 100);\n\n if (recent.length < 2) return 0;\n\n const first = recent[0];\n const last = recent[recent.length - 1];\n if (!first || !last) return 0;\n const dt = last.time - first.time;\n\n return dt > 0 ? (last.y - first.y) / dt : 0;\n }\n\n function clampAboveMaxSnap(translateY: number): number {\n // The sheet is only as tall as its MAX snap point, with its bottom pinned to the\n // screen bottom (translateY 0 == fully open to max). Letting translateY go below\n // that (negative) would lift the sheet's bottom edge off the screen and reveal the\n // background in the gap beneath it — which looks broken. So pin it: you cannot drag\n // the sheet frame above its largest detent. This is also what native iOS does — the\n // frame is firm at the top detent; only inner scroll content overscrolls.\n const topLimit = sheetHeight - maxSnapPoint * effectiveHeight;\n return translateY < topLimit ? topLimit : translateY;\n }\n\n function applyLockedDragResistance(rawTranslateY: number): number {\n // Base translateY when at start snap point: sheetHeight - (startSnapPoint * effectiveHeight)\n const baseTranslateY = sheetHeight - (dragState.startSnapPoint * effectiveHeight);\n const dragDistance = rawTranslateY - baseTranslateY;\n\n if (dragDistance > 0) {\n const {lockedDragResistance, maxLockedDrag} = CONFIG.gesture;\n const resistedDrag = Math.min(dragDistance * lockedDragResistance, maxLockedDrag);\n return baseTranslateY + resistedDrag;\n }\n return rawTranslateY;\n }\n\n function findNearestSnapPoint(translateY: number, velocityInfluence = 0): number {\n // Calculate progress: translateY = sheetHeight - (progress * effectiveHeight)\n // So: progress = (sheetHeight - translateY) / effectiveHeight\n const currentProgress = (sheetHeight - translateY) / effectiveHeight;\n const targetProgress = currentProgress + velocityInfluence;\n\n return allSnapPoints.reduce((nearest, sp) =>\n Math.abs(targetProgress - sp) < Math.abs(targetProgress - nearest) ? sp : nearest\n , allSnapPoints[0] ?? 0);\n }\n\n function determineSnapPoint(): number {\n // Calculate progress: translateY = sheetHeight - (progress * effectiveHeight)\n // So: progress = (sheetHeight - translateY) / effectiveHeight\n const currentProgress = (sheetHeight - currentTranslateY) / effectiveHeight;\n const velocity = calculateVelocity();\n\n if (!dragToClose) {\n return dragState.startSnapPoint;\n }\n\n const {closeVelocityThreshold, closeProgressThreshold, velocityInfluence} = CONFIG.gesture;\n\n // Fast swipe handling\n if (Math.abs(velocity) > closeVelocityThreshold) {\n if (velocity > 0) {\n // Swipe down - close or go to lower snap\n const lowerSnaps = allSnapPoints.filter(sp => sp < dragState.startSnapPoint);\n const lowest = lowerSnaps[lowerSnaps.length - 1];\n if (lowest !== undefined && currentProgress >= lowest * 0.5) {\n return lowest;\n }\n return 0;\n } else {\n // Swipe up - go to higher snap\n const higherSnaps = allSnapPoints.filter(sp => sp > currentProgress);\n return higherSnaps[0] ?? Math.max(...snapPoints);\n }\n }\n\n // Check close threshold\n const progressDrop = dragState.startSnapPoint - currentProgress;\n if (progressDrop > closeProgressThreshold) {\n return 0;\n }\n\n // Velocity is in px/ms, convert to progress units (divide by effectiveHeight, multiply by 1000 for scale)\n const velocityInProgressUnits = -velocity / effectiveHeight * 1000;\n return findNearestSnapPoint(currentTranslateY, velocityInProgressUnits * velocityInfluence);\n }\n\n // ============================================\n // TOUCH EVENT HANDLERS\n // ============================================\n\n function isInDraggableArea(target: HTMLElement): boolean {\n return !!(target.closest('[data-bottom-sheet-handle]'));\n }\n\n function isInteractive(target: HTMLElement): boolean {\n return !!target.closest('button, a, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');\n }\n\n function shouldIgnoreDrag(target: HTMLElement): boolean {\n return !!target.closest('[data-bottom-sheet-no-drag]');\n }\n\n /**\n * Resolve the scrollable region ([data-bottom-sheet-scroll]) that contains the\n * gesture target. Resolved per-gesture (not once at open-time) so that content\n * mounted lazily after the sheet opened — and nested/multiple scroll regions —\n * are detected correctly. Returns the nearest scrollable ancestor, or null.\n */\n function getScrollableFromTarget(target: HTMLElement): HTMLElement | null {\n return target.closest<HTMLElement>('[data-bottom-sheet-scroll]');\n }\n\n /**\n * Install a one-shot capture-phase click listener to suppress the ghost click\n * that fires after a committed drag from an interactive element.\n */\n function suppressNextClick() {\n if (!sheetRef) return;\n const handler = (e: Event) => {\n e.preventDefault();\n e.stopPropagation();\n sheetRef?.removeEventListener('click', handler, true);\n };\n sheetRef.addEventListener('click', handler, true);\n // Safety: auto-remove after a short delay if click never fires\n setTimeout(() => sheetRef?.removeEventListener('click', handler, true), 300);\n }\n\n /**\n * Reset all pending/two-phase gesture state fields.\n */\n function resetPendingState() {\n dragState.isPending = false;\n dragState.isInteractiveStart = false;\n dragState.committed = false;\n dragState.inScrollable = false;\n }\n\n /**\n * Initialize common drag tracking fields shared by both touch and pointer paths.\n */\n function initDragTracking(clientX: number, clientY: number) {\n dragState.startX = clientX;\n dragState.startY = clientY;\n dragState.lastY = clientY;\n dragState.currentY = currentTranslateY;\n dragState.deltaY = 0;\n dragState.startSnapPoint = Math.round((sheetHeight - currentTranslateY) / effectiveHeight * 100) / 100;\n clearHistory();\n pushHistory(clientY);\n }\n\n /**\n * Commit to dragging: transition from pending state to active drag.\n * Re-anchors startY/currentY to the current position to prevent visual jump.\n */\n function commitToDrag(clientY: number) {\n dragState.isPending = false;\n dragState.committed = true;\n // Re-anchor to current position so sheet doesn't jump\n dragState.startY = clientY;\n dragState.currentY = currentTranslateY;\n clearHistory();\n pushHistory(clientY);\n\n interruptAnimation();\n if (scaleTarget) scaleTarget.style.transition = 'none';\n isDragging = true;\n }\n\n /**\n * Check if the pending gesture should be resolved.\n * Returns: 'drag' if vertical threshold exceeded and should drag sheet,\n * 'abort' if horizontal wins or user wants to scroll content,\n * 'pending' if undecided.\n */\n function resolvePendingGesture(clientX: number, clientY: number): 'drag' | 'abort' | 'pending' {\n const {dragActivationThreshold, horizontalDeadZone} = CONFIG.gesture;\n const absX = Math.abs(clientX - dragState.startX);\n const absY = Math.abs(clientY - dragState.startY);\n const deltaY = clientY - dragState.startY; // positive = finger moving down\n\n // Horizontal threshold exceeded — let native behavior handle it (slider, scroll, etc.)\n if (absX >= dragActivationThreshold) {\n return 'abort';\n }\n\n // Vertical threshold exceeded and angle is more vertical than horizontal\n if (absY >= dragActivationThreshold && absY > absX * horizontalDeadZone) {\n // Inside a scrollable area: only commit to drag if pulling DOWN from top (dismiss gesture).\n // If swiping UP (to scroll content down), abort and let native scroll work.\n if (dragState.inScrollable) {\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n if (deltaY > 0 && isScrolledToTop) {\n // Pulling down from top → drag sheet (dismiss)\n return 'drag';\n }\n // Swiping up, or not at top → let native scroll handle it\n return 'abort';\n }\n return 'drag';\n }\n\n return 'pending';\n }\n\n function handleTouchStart(e: TouchEvent) {\n if (dragState.isTouchActive) return;\n\n const target = e.target as HTMLElement;\n const inDraggableArea = isInDraggableArea(target);\n // Resolve scrollable region from the actual target so lazily-mounted content is detected.\n scrollableElement = getScrollableFromTarget(target);\n const inScrollable = !!scrollableElement;\n const isInteractiveTarget = isInteractive(target);\n\n // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all\n if (shouldIgnoreDrag(target) && !inDraggableArea) {\n return;\n }\n\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n\n if (inScrollable && !isScrolledToTop && !inDraggableArea) {\n dragState.shouldPreventScroll = false;\n return;\n }\n\n // Initialize tracking for all cases\n const touch = e.touches[0];\n if (!touch) return;\n dragState.isTouchActive = true;\n initDragTracking(touch.clientX, touch.clientY);\n\n // Interactive element (button, input, slider, etc.) NOT in draggable area:\n // Enter pending state — don't commit to drag yet, let threshold decide\n if (isInteractiveTarget && !inDraggableArea) {\n dragState.isPending = true;\n dragState.isInteractiveStart = true;\n dragState.committed = false;\n dragState.inScrollable = inScrollable;\n dragState.shouldPreventScroll = true; // Will be used if we commit\n\n // Do NOT set isDragging or interrupt the animation yet — allow native\n // touch behavior until the gesture commits, and don't call e.preventDefault().\n return;\n }\n\n // Non-interactive element: immediately commit to dragging (existing behavior)\n dragState.isPending = false;\n dragState.isInteractiveStart = false;\n dragState.committed = true;\n dragState.shouldPreventScroll = !(inScrollable && isScrolledToTop && !inDraggableArea);\n\n interruptAnimation();\n if (scaleTarget) scaleTarget.style.transition = 'none';\n isDragging = true;\n }\n\n function handleTouchMove(e: TouchEvent) {\n if (!dragState.isTouchActive) return;\n\n const touch = e.touches[0];\n if (!touch) return;\n const clientY = touch.clientY;\n const clientX = touch.clientX;\n\n // ── Two-phase: resolve pending gesture from interactive element ──\n if (dragState.isPending) {\n pushHistory(clientY);\n const result = resolvePendingGesture(clientX, clientY);\n\n if (result === 'drag') {\n // Vertical intent wins → commit to drag\n commitToDrag(clientY);\n dragState.shouldPreventScroll = true;\n e.preventDefault();\n return; // First real drag frame happens on next move\n }\n if (result === 'abort') {\n // Horizontal intent wins → abort, let native behavior (slider, etc.)\n dragState.isPending = false;\n dragState.committed = false;\n dragState.isTouchActive = false;\n dragState.isInteractiveStart = false;\n return;\n }\n // Still pending — do nothing, let native behavior continue\n return;\n }\n\n // ── Normal drag logic (committed) ──\n const moveDelta = clientY - dragState.startY;\n dragState.deltaY = clientY - dragState.lastY;\n dragState.lastY = clientY;\n pushHistory(clientY);\n\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n if (isScrolledToTop && moveDelta > 0) dragState.shouldPreventScroll = true;\n if (!dragState.shouldPreventScroll) return;\n\n e.preventDefault();\n\n let translateY = dragState.currentY + moveDelta;\n translateY = clampAboveMaxSnap(translateY);\n if (!dragToClose) translateY = applyLockedDragResistance(translateY);\n translateY = Math.min(translateY, sheetHeight);\n\n currentTranslateY = translateY;\n renderPosition(translateY);\n }\n\n function handleTouchEnd(e: TouchEvent) {\n if (e.touches.length > 0 || !dragState.isTouchActive) return;\n\n dragState.isTouchActive = false;\n\n // ── Two-phase: pending never resolved (tap) → let native click fire ──\n if (dragState.isPending) {\n resetPendingState();\n return;\n }\n\n // ── Committed drag from interactive element → suppress ghost click ──\n if (dragState.committed && dragState.isInteractiveStart) {\n suppressNextClick();\n }\n resetPendingState();\n\n if (!dragState.shouldPreventScroll) {\n isDragging = false;\n return;\n }\n\n isDragging = false;\n dragState.shouldPreventScroll = false;\n\n settleFromRelease();\n }\n\n // ============================================\n // POINTER EVENT HANDLERS (Desktop)\n // ============================================\n\n function handlePointerDown(e: PointerEvent) {\n if (e.pointerType === 'touch' || e.button !== 0 || dragState.activePointerId !== null) return;\n\n const target = e.target as HTMLElement;\n const inDraggableArea = isInDraggableArea(target);\n // Resolve scrollable region from the actual target so lazily-mounted content is detected.\n scrollableElement = getScrollableFromTarget(target);\n const inScrollable = !!scrollableElement;\n const isInteractiveTarget = isInteractive(target);\n const shouldIgnoreDragTarget = shouldIgnoreDrag(target);\n\n // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all\n if (shouldIgnoreDragTarget && !inDraggableArea) return;\n\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\n if (inScrollable && !isScrolledToTop && !inDraggableArea) return;\n\n dragState.activePointerId = e.pointerId;\n initDragTracking(e.clientX, e.clientY);\n\n // Interactive element NOT in draggable area:\n // Enter pending state — don't commit yet, preserve native focus/click\n if (isInteractiveTarget && !inDraggableArea) {\n dragState.isPending = true;\n dragState.isInteractiveStart = true;\n dragState.committed = false;\n dragState.inScrollable = inScrollable;\n\n // Do NOT call e.preventDefault() — preserve focus for inputs\n // Do NOT call setPointerCapture — allow native hover/click behavior\n // Do NOT set isDragging or disable transitions\n return;\n }\n\n // Non-interactive element: immediately commit to dragging\n dragState.isPending = false;\n dragState.isInteractiveStart = false;\n dragState.committed = true;\n\n interruptAnimation();\n if (scaleTarget) scaleTarget.style.transition = 'none';\n sheetRef?.setPointerCapture(e.pointerId);\n isDragging = true;\n e.preventDefault();\n }\n\n function handlePointerMove(e: PointerEvent) {\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\n if (!isDragging && !dragState.isPending) return;\n\n const clientY = e.clientY;\n const clientX = e.clientX;\n\n // ── Two-phase: resolve pending gesture from interactive element ──\n if (dragState.isPending) {\n pushHistory(clientY);\n const result = resolvePendingGesture(clientX, clientY);\n\n if (result === 'drag') {\n // Vertical intent wins → commit to drag\n commitToDrag(clientY);\n sheetRef?.setPointerCapture(e.pointerId);\n e.preventDefault();\n return; // First real drag frame happens on next move\n }\n if (result === 'abort') {\n // Horizontal intent wins → abort, let native behavior\n dragState.isPending = false;\n dragState.committed = false;\n dragState.activePointerId = null;\n dragState.isInteractiveStart = false;\n return;\n }\n // Still pending — do nothing\n return;\n }\n\n // ── Normal drag logic (committed) ──\n dragState.deltaY = clientY - dragState.lastY;\n dragState.lastY = clientY;\n pushHistory(clientY);\n\n let translateY = dragState.currentY + clientY - dragState.startY;\n translateY = clampAboveMaxSnap(translateY);\n if (!dragToClose) translateY = applyLockedDragResistance(translateY);\n translateY = Math.min(translateY, sheetHeight);\n\n currentTranslateY = translateY;\n renderPosition(translateY);\n\n e.preventDefault();\n }\n\n function handlePointerUp(e: PointerEvent) {\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\n\n try {\n sheetRef?.releasePointerCapture(e.pointerId);\n } catch {\n }\n dragState.activePointerId = null;\n\n // ── Two-phase: pending never resolved (tap) → let native click fire ──\n if (dragState.isPending) {\n resetPendingState();\n return;\n }\n\n // ── Committed drag from interactive element → suppress ghost click ──\n if (dragState.committed && dragState.isInteractiveStart) {\n suppressNextClick();\n }\n resetPendingState();\n\n if (!isDragging) return;\n isDragging = false;\n\n settleFromRelease();\n }\n\n function handlePointerCancel(e: PointerEvent) {\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\n\n try {\n sheetRef?.releasePointerCapture(e.pointerId);\n } catch {\n }\n dragState.activePointerId = null;\n\n // If pending, just reset — nothing visual happened\n if (dragState.isPending) {\n resetPendingState();\n return;\n }\n resetPendingState();\n\n if (!isDragging) return;\n isDragging = false;\n\n // No velocity on cancel — settle to the nearest snap point at rest.\n const target = sheetHeight - findNearestSnapPoint(currentTranslateY, 0) * effectiveHeight;\n animateTo(target, CONFIG.animation.snap, 0);\n }\n\n function handleCloseButtonClick(e: MouseEvent) {\n e.stopPropagation();\n doClose();\n }\n\n function handleBackdropPointerDown(e: PointerEvent) {\n // Record a press that begins ON the backdrop (and only the backdrop). Used to tell\n // a deliberate backdrop tap apart from the ghost pointerup described below.\n backdropPressPointerId = e.target === backdropRef ? e.pointerId : null;\n }\n\n function handleBackdropClick(e: PointerEvent) {\n // Dismiss only when THIS press both started and ended on the backdrop. Gating on the\n // press origin (not an isAnimating timer) keeps a deliberate tap instant — even mid\n // open-animation — while ignoring the stray release that fires when a press begun\n // elsewhere lifts over a just-mounted backdrop (e.g. long-press a button to open the\n // sheet, then release: that pointerup lands on the backdrop but never pressed it).\n const startedOnBackdrop = backdropPressPointerId === e.pointerId;\n backdropPressPointerId = null;\n if (!closeOnBackdrop || e.target !== backdropRef || isDragging || !startedOnBackdrop) return;\n doClose();\n }\n\n function handleKeydown(e: KeyboardEvent) {\n if (e.key === 'Escape' && isVisible && bottomSheetStore.isTopmost(id)) {\n e.preventDefault();\n doClose();\n }\n }\n\n // Svelte action for touch events\n function touchEvents(node: HTMLElement) {\n node.addEventListener('touchstart', handleTouchStart, {passive: false});\n node.addEventListener('touchmove', handleTouchMove, {passive: false});\n node.addEventListener('touchend', handleTouchEnd, {passive: true});\n\n return {\n destroy() {\n node.removeEventListener('touchstart', handleTouchStart);\n node.removeEventListener('touchmove', handleTouchMove);\n node.removeEventListener('touchend', handleTouchEnd);\n }\n };\n }\n</script>\n\n<svelte:window onkeydown={handleKeydown}/>\n\n{#if isVisible}\n <!-- Backdrop -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div\n bind:this={backdropRef}\n class={r.backdrop()}\n style:z-index={zIndex}\n onpointerdown={handleBackdropPointerDown}\n onpointerup={handleBackdropClick}\n ></div>\n\n <!-- Sheet -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div\n bind:this={sheetRef}\n class={r.sheet()}\n style:z-index={zIndex + 1}\n style:height=\"{maxSheetHeight}px\"\n data-bottom-sheet-surface\n role=\"dialog\"\n aria-modal=\"true\"\n tabindex={-1}\n use:touchEvents\n onpointerdown={handlePointerDown}\n onpointermove={handlePointerMove}\n onpointerup={handlePointerUp}\n onpointercancel={handlePointerCancel}\n >\n <!-- Drag Handle -->\n <div class={r.handle()} data-bottom-sheet-handle>\n <div class={r.handleBar()}></div>\n </div>\n\n <!-- Header -->\n {#if header}\n <div class={r.header()} data-bottom-sheet-header>\n {@render header()}\n {#if showCloseButton}\n <button\n class={r.closeButton()}\n data-bottom-sheet-close\n onclick={handleCloseButtonClick}\n aria-label=\"Close\"\n type=\"button\"\n >\n <IconX class=\"size-4\"/>\n </button>\n {/if}\n </div>\n {/if}\n\n <!-- Content -->\n <div bind:this={contentRef} class={r.content()} data-bottom-sheet-content>\n {#if children}\n {@render children()}\n {/if}\n </div>\n </div>\n{/if}\n\n<style>\n /* Consumer-marked scroll region inside the sheet body. */\n [data-bottom-sheet-content] :global([data-bottom-sheet-scroll]) {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n overscroll-behavior: contain;\n -webkit-overflow-scrolling: touch;\n min-height: 0;\n touch-action: pan-y;\n }\n\n /* Suppress native gestures on header children so the drag is captured. */\n [data-bottom-sheet-header] :global(*) {\n touch-action: none;\n user-select: none;\n }\n\n /* Extend the sheet's own surface below its bottom edge. The sheet is only as tall as\n its max snap point; if it ever sits a hair above the screen bottom — the open spring's\n slight overshoot, or sub-pixel rounding — this keeps the sliver beneath it sheet-colored\n instead of letting the background show through. The sheet is overflow-visible so this is\n not clipped, and it rides along with the sheet's transform. */\n :global([data-bottom-sheet-surface]::after) {\n content: '';\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n height: 100%;\n background-color: inherit;\n pointer-events: none;\n }\n</style>\n"
19
- },
20
- {
21
- "path": "src/lib/components/bottom-sheet/bottomSheetStore.svelte.ts",
22
- "target": "bottomSheetStore.svelte.ts",
23
- "type": "ts",
24
- "content": "/**\n * Bottom Sheet store — Svelte 5 runes.\n *\n * A small singleton that tracks the stack of open bottom sheets so multiple\n * sheets can layer correctly (z-index), report drag progress, and resolve which\n * sheet is topmost (for Escape handling). Sheets register on mount and push/pop\n * themselves from the stack as they open/close. Exposed publicly so apps can\n * drive sheets programmatically (open/close/closeTop) in addition to `bind:open`.\n */\n\nexport interface BottomSheetConfig {\n id: string;\n snapPoints: number[];\n defaultSnapPoint: number;\n closeOnBackdrop: boolean;\n}\n\nexport interface SheetStackItem {\n id: string;\n zIndex: number;\n progress: number;\n config: BottomSheetConfig;\n}\n\nconst BASE_Z_INDEX = 1000;\nconst Z_INDEX_INCREMENT = 10;\n\nfunction createBottomSheetStore() {\n // Stack of active sheets (bottom to top)\n let sheetStack = $state<SheetStackItem[]>([]);\n\n // Registry of all sheet configs\n let registry = $state<Map<string, BottomSheetConfig>>(new Map());\n\n // Container refs for scale effect\n let containerRefs = $state<Map<string, HTMLElement>>(new Map());\n\n /**\n * Register a sheet with its config\n */\n function register(id: string, config: Omit<BottomSheetConfig, 'id'>): void {\n registry.set(id, {id, ...config});\n }\n\n /**\n * Unregister a sheet\n */\n function unregister(id: string): void {\n registry.delete(id);\n containerRefs.delete(id);\n // Remove from stack if present\n sheetStack = sheetStack.filter(s => s.id !== id);\n }\n\n /**\n * Open a sheet by ID\n */\n function open(id: string): void {\n const config = registry.get(id);\n if (!config) {\n // Sheet was not registered (component not mounted yet) — ignore.\n return;\n }\n\n // Check if already in stack\n if (sheetStack.some(s => s.id === id)) {\n return;\n }\n\n // Calculate z-index based on stack position\n const zIndex = BASE_Z_INDEX + (sheetStack.length * Z_INDEX_INCREMENT);\n\n sheetStack = [...sheetStack, {\n id,\n zIndex,\n progress: 0,\n config\n }];\n }\n\n /**\n * Close a sheet by ID\n */\n function close(id: string): void {\n sheetStack = sheetStack.filter(s => s.id !== id);\n // Recalculate z-indices for remaining sheets\n sheetStack = sheetStack.map((sheet, index) => ({\n ...sheet,\n zIndex: BASE_Z_INDEX + (index * Z_INDEX_INCREMENT)\n }));\n }\n\n /**\n * Close the topmost sheet\n */\n function closeTop(): void {\n const top = sheetStack[sheetStack.length - 1];\n if (top) {\n close(top.id);\n }\n }\n\n /**\n * Update progress for a sheet (0-1)\n */\n function updateProgress(id: string, progress: number): void {\n const index = sheetStack.findIndex(s => s.id === id);\n const sheet = sheetStack[index];\n if (sheet) {\n sheet.progress = Math.max(0, Math.min(1, progress));\n }\n }\n\n /**\n * Get z-index for a sheet\n */\n function getZIndex(id: string): number {\n const sheet = sheetStack.find(s => s.id === id);\n return sheet?.zIndex ?? BASE_Z_INDEX;\n }\n\n /**\n * Check if a sheet is open\n */\n function isOpen(id: string): boolean {\n return sheetStack.some(s => s.id === id);\n }\n\n /**\n * Check if a sheet is the topmost\n */\n function isTopmost(id: string): boolean {\n return sheetStack[sheetStack.length - 1]?.id === id;\n }\n\n /**\n * Get progress for a sheet\n */\n function getProgress(id: string): number {\n const sheet = sheetStack.find(s => s.id === id);\n return sheet?.progress ?? 0;\n }\n\n /**\n * Attach container ref for scale effect\n */\n function attachContainer(sheetId: string, ref: HTMLElement): void {\n containerRefs.set(sheetId, ref);\n }\n\n /**\n * Get container ref for a sheet\n */\n function getContainer(sheetId: string): HTMLElement | undefined {\n return containerRefs.get(sheetId);\n }\n\n /**\n * Get combined backdrop opacity from all sheets\n */\n function getCombinedBackdropOpacity(): number {\n // Use the topmost sheet's progress for backdrop\n const top = sheetStack[sheetStack.length - 1];\n return top ? top.progress * 0.5 : 0;\n }\n\n /**\n * Get the current stack\n */\n function getStack(): SheetStackItem[] {\n return sheetStack;\n }\n\n return {\n get stack() { return sheetStack; },\n get hasOpenSheets() { return sheetStack.length > 0; },\n\n register,\n unregister,\n open,\n close,\n closeTop,\n updateProgress,\n getZIndex,\n isOpen,\n isTopmost,\n getProgress,\n attachContainer,\n getContainer,\n getCombinedBackdropOpacity,\n getStack\n };\n}\n\n// Export singleton instance\nexport const bottomSheetStore = createBottomSheetStore();\n"
25
- },
26
- {
27
- "path": "src/lib/components/bottom-sheet/index.ts",
28
- "target": "index.ts",
29
- "type": "ts",
30
- "content": "export {default as BottomSheet} from './BottomSheet.svelte'\nexport {bottomSheetStore} from './bottomSheetStore.svelte.js'\nexport {bottomSheetRecipe} from './BottomSheet.recipe'\nexport type {BottomSheetRecipeProps, BottomSheetProps} from './BottomSheet.recipe'\nexport type {BottomSheetConfig, SheetStackItem} from './bottomSheetStore.svelte.js'\n"
31
- }
32
- ],
33
- "dependencies": [
34
- "tailwind-variants"
35
- ],
36
- "peerDependencies": [
37
- "svelte"
38
- ],
39
- "registryDependencies": [],
40
- "sharedModules": {
41
- "utils": false,
42
- "icons": true,
43
- "hooks": false
44
- },
45
- "exports": [
46
- "BottomSheet",
47
- "bottomSheetStore",
48
- "bottomSheetRecipe"
49
- ]
50
- }
1
+ {
2
+ "$schema": "https://tera-system-ui/registry-item.schema.json",
3
+ "name": "bottom-sheet",
4
+ "type": "registry:component",
5
+ "description": "Draggable iOS-style bottom sheet with snap points, container-scale effect, and stacking.",
6
+ "recipe": "src/lib/components/bottom-sheet/BottomSheet.recipe.ts",
7
+ "files": [
8
+ {
9
+ "path": "src/lib/components/bottom-sheet/BottomSheet.recipe.ts",
10
+ "target": "BottomSheet.recipe.ts",
11
+ "type": "recipe",
12
+ "content": "import {tv, type VariantProps} from \"tailwind-variants\";\r\nimport type {Snippet} from \"svelte\";\r\n\r\n/**\r\n * BottomSheet recipe — the canonical styled layer for the draggable sheet.\r\n *\r\n * An iOS-style bottom sheet with snap points, gesture dragging, container-scale\r\n * effect, and stacking (custom behavior — not bits-ui). The recipe is slotted by\r\n * role — backdrop / sheet / handle / handleBar / header / closeButton / content —\r\n * so an app can restyle any part independently. Only the *themeable, static*\r\n * surface lives here; the per-frame transforms (translate / scale / opacity) are\r\n * driven imperatively from the component and the runtime transition duration is\r\n * passed through the `--transition-duration` CSS var.\r\n *\r\n * The `constrained` variant switches fixed → absolute positioning when the sheet\r\n * is bounded inside a container; `dragging` carries the grabbing cursor.\r\n *\r\n * Primary customization surface: extend with `tv({ extend: bottomSheetRecipe, ... })`\r\n * in typed code. Lighter rung: the per-component CSS var (--tera-bottom-sheet-radius)\r\n * retunes the top corner radius with no code change; it defaults to the prior\r\n * literal value inline.\r\n */\r\nexport const bottomSheetRecipe = tv({\r\n slots: {\r\n backdrop: 'fixed inset-0 bg-black opacity-0 touch-none [will-change:opacity] [-webkit-tap-highlight-color:transparent] transition-opacity duration-[var(--transition-duration,0.35s)] ease-out',\r\n sheet: 'fixed bottom-0 left-0 right-0 flex flex-col overflow-visible bg-surface-raised rounded-t-[var(--tera-bottom-sheet-radius,12px)] [will-change:translate] [translate:0_100%] [transform:translateZ(0)] [backface-visibility:hidden] transition-[translate] duration-[var(--transition-duration,0.35s)] ease-[cubic-bezier(0.2,0.9,0.36,1)]',\r\n handle: 'flex shrink-0 items-center justify-center px-4 pt-2 pb-1 cursor-grab touch-none active:cursor-grabbing',\r\n handleBar: 'h-[5px] w-9 rounded-[2.5px] bg-text-tertiary',\r\n header: 'relative shrink-0 cursor-grab touch-none select-none border-b border-border-default px-4 pt-1 pb-3 active:cursor-grabbing',\r\n closeButton: 'absolute right-2 top-[calc(50%-4px)] flex h-8 w-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 text-text-tertiary [touch-action:manipulation] [-webkit-tap-highlight-color:transparent] transition-colors hover:bg-surface-hover hover:text-text-primary active:bg-surface-hover',\r\n content: 'flex min-h-0 flex-1 flex-col overflow-hidden [touch-action:pan-y]',\r\n },\r\n variants: {\r\n // Sheet bounded inside a container (vs. the viewport): switch fixed → absolute.\r\n constrained: {\r\n true: {backdrop: 'absolute', sheet: 'absolute'},\r\n false: {},\r\n },\r\n // Active drag: grabbing cursor + suppress selection.\r\n dragging: {\r\n true: {sheet: 'cursor-grabbing select-none'},\r\n false: {},\r\n },\r\n },\r\n defaultVariants: {\r\n constrained: false,\r\n dragging: false,\r\n },\r\n});\r\n\r\nexport type BottomSheetRecipeProps = VariantProps<typeof bottomSheetRecipe>;\r\n\r\nexport interface BottomSheetProps {\r\n /** Unique identifier for this sheet (drives stacking/z-index registry). */\r\n id: string;\r\n /** Whether the sheet is open. Bindable. */\r\n open?: boolean;\r\n /** Snap positions as viewport fractions (0–1), e.g. [0.25, 0.5, 0.9]. */\r\n snapPoints?: number[];\r\n /** Index into `snapPoints` to settle at when opening. */\r\n defaultSnapPoint?: number;\r\n /** Container element to constrain the sheet within (constrained mode). */\r\n containerRef?: HTMLElement | null;\r\n /** Element to apply the background scale effect to (defaults to containerRef). */\r\n scaleTargetRef?: HTMLElement | null;\r\n /** Constrain the sheet inside `containerRef` (absolute positioning). */\r\n constrainToContainer?: boolean;\r\n /** Scale/round the background target as the sheet opens (iOS effect). */\r\n scaleBackground?: boolean;\r\n /** Allow dragging down past the lowest snap point to close. */\r\n dragToClose?: boolean;\r\n /** Close when the backdrop is tapped. */\r\n closeOnBackdrop?: boolean;\r\n /** Show a close (X) button in the header (requires the `header` snippet). */\r\n showCloseButton?: boolean;\r\n /**\r\n * Keep the browser UI tint (iOS Safari status bar / Android address bar) matching the page\r\n * while the sheet is open. The full-screen backdrop darkens the area behind the status bar;\r\n * iOS Safari, when the page declares no `<meta name=\"theme-color\">`, re-samples that area and\r\n * flips the status bar dark — losing the immersive look. When true (the default) the sheet,\r\n * only if the page has no `theme-color` of its own, adds one pinned to the page background for\r\n * its open lifetime and removes it on close. A page that sets its own tint is left untouched.\r\n */\r\n syncThemeColor?: boolean;\r\n /**\r\n * Prevent the browser's native pull-to-refresh / overscroll-reload from hijacking a downward\r\n * drag that starts on an interactive element inside the sheet. The two-phase gesture only\r\n * claims the touch (`preventDefault`) once the drag commits past its activation threshold, so a\r\n * *slow* pull can let the browser start pull-to-refresh first (a quick flick clears the\r\n * threshold immediately and does not). When true (the default) the sheet pins\r\n * `overscroll-behavior-y: contain` on the document root for its open lifetime — reference-counted\r\n * across stacked sheets, the page's original value restored on close. Reliable on Android and\r\n * iOS 16+. Set false if the host app manages page overscroll itself.\r\n */\r\n preventPullToRefresh?: boolean;\r\n /**\r\n * Whether pressing the browser Back button closes the sheet. Default true (matching Dialog).\r\n *\r\n * While open, the sheet pushes a dummy history entry (same URL); Back pops it and closes the\r\n * sheet instead of navigating. This is also what makes \"Back closes the TOPMOST overlay\" work\r\n * across types: entries are LIFO, so a sheet opened over a Dialog takes the Back press rather\r\n * than letting it close the dialog underneath — but only because the sheet pushes one too.\r\n */\r\n closeOnNavigateBack?: boolean;\r\n /** Called after the sheet finishes closing. */\r\n onClose?: () => void;\r\n /** Called whenever the open state changes. */\r\n onOpenChange?: (open: boolean) => void;\r\n /** Main body content. */\r\n children?: Snippet;\r\n /** Header content; renders the header bar when set. */\r\n header?: Snippet;\r\n}\r\n"
13
+ },
14
+ {
15
+ "path": "src/lib/components/bottom-sheet/BottomSheet.svelte",
16
+ "target": "BottomSheet.svelte",
17
+ "type": "svelte",
18
+ "content": "<script module lang=\"ts\">\r\n // ============================================\r\n // PAGE-ROOT OVERSCROLL GUARD (native pull-to-refresh)\r\n // ============================================\r\n // The two-phase drag doesn't preventDefault() until a gesture commits past the ~10px\r\n // activation threshold, so a SLOW downward pull that STARTS on an interactive element\r\n // (button / input / link / [tabindex]) leaves a window where the browser can claim the\r\n // gesture as native pull-to-refresh / overscroll-reload before the sheet does — which is\r\n // why only a *slow* pull triggers it (a quick flick clears the threshold on the first\r\n // touchmove and preventDefault()s first). `overscroll-behavior-y: contain` on the document\r\n // root closes that window for the sheet's whole open lifetime WITHOUT touching the gesture\r\n // logic. Shared + reference-counted across every mounted sheet so stacked sheets apply it\r\n // once and the page's original inline value is restored only when the last sheet releases.\r\n let overscrollGuardCount = 0;\r\n let savedRootOverscrollY = '';\r\n let savedBodyOverscrollY = '';\r\n\r\n function acquireOverscrollGuard(): void {\r\n if (typeof document === 'undefined') return;\r\n if (overscrollGuardCount === 0) {\r\n const root = document.documentElement;\r\n const {body} = document;\r\n savedRootOverscrollY = root.style.overscrollBehaviorY;\r\n savedBodyOverscrollY = body.style.overscrollBehaviorY;\r\n // Set both: browsers disagree on whether html or body is the root scroller.\r\n root.style.overscrollBehaviorY = 'contain';\r\n body.style.overscrollBehaviorY = 'contain';\r\n }\r\n overscrollGuardCount++;\r\n }\r\n\r\n function releaseOverscrollGuard(): void {\r\n if (typeof document === 'undefined' || overscrollGuardCount === 0) return;\r\n overscrollGuardCount--;\r\n if (overscrollGuardCount === 0) {\r\n document.documentElement.style.overscrollBehaviorY = savedRootOverscrollY;\r\n document.body.style.overscrollBehaviorY = savedBodyOverscrollY;\r\n }\r\n }\r\n</script>\r\n\r\n<script lang=\"ts\">\r\n import {onDestroy, onMount} from 'svelte';\r\n import {bottomSheetStore} from './bottomSheetStore.svelte.js';\r\n import {bottomSheetRecipe, type BottomSheetProps} from './BottomSheet.recipe';\r\n import {IconX} from \"$lib/internal/icons\";\r\n import {closeOnBackNavigation, overlayStack} from '$lib/hooks';\r\n\r\n // ============================================\r\n // CONFIGURABLE CONSTANTS\r\n // Adjust these values to customize the bottom sheet behavior\r\n // ============================================\r\n\r\n const CONFIG = {\r\n // Scale effect for background container\r\n scale: {\r\n factor: 0.06, // How much to scale down (6%)\r\n borderRadius: 16, // Border radius when scaled (px)\r\n translateY: 20, // Vertical offset when scaled (px)\r\n origin: 'top center' // Transform origin\r\n },\r\n\r\n // Backdrop\r\n backdrop: {\r\n maxOpacity: 0.5 // Maximum opacity when fully open\r\n },\r\n\r\n // Spring physics for open / close / settle, PLAYED ON THE COMPOSITOR via the Web\r\n // Animations API (see animateTo) so motion holds the display's native refresh rate\r\n // (e.g. 120Hz) even under main-thread load. `response` is the approximate settle\r\n // time (s); `damping` is the damping ratio (1 = critical/no bounce, <1 adds a little\r\n // life). The spring is simulated ONCE per gesture — seeded with the finger's release\r\n // velocity so motion continues instead of restarting — and emitted as compositor\r\n // keyframes; there is no per-frame JavaScript. `close` finalizes the instant it\r\n // reaches offscreen (simulateSpring hideOnArrival) so there is no lingering tail.\r\n animation: {\r\n open: {response: 0.4, damping: 0.86}, // present — quick, no bounce\r\n close: {response: 0.26, damping: 0.85}, // dismiss — fast, finalizes on arrival\r\n snap: {response: 0.32, damping: 0.84}, // moving between snap points\r\n restDistance: 0.5, // px from target at which the simulated spring is \"settled\"\r\n restVelocity: 40 // px/s below which the simulated spring is \"at rest\"\r\n },\r\n\r\n // Gesture handling\r\n gesture: {\r\n velocityInfluence: 0.01, // How much velocity affects snap selection\r\n closeVelocityThreshold: 0.35, // Velocity to trigger close (px/ms)\r\n closeProgressThreshold: 0.35, // Progress to trigger close (0-1)\r\n maxLockedDrag: 60, // Max drag when dragToClose disabled (px)\r\n lockedDragResistance: 0.3, // Resistance when dragToClose disabled\r\n // Two-phase gesture recognition for interactive elements (iOS-style)\r\n dragActivationThreshold: 10, // Vertical px to commit to drag from interactive element\r\n pullClaimThreshold: 3, // Vertical px to CLAIM the gesture (preventDefault) BEFORE commit, so iOS Safari can't start pull-to-refresh during a slow pull. Below dragActivationThreshold; tune on-device.\r\n horizontalDeadZone: 1.2, // If abs(dx)/abs(dy) > this, abort drag (let native horizontal win)\r\n tapDistanceThreshold: 6 // Max total movement (px) to still count as a tap\r\n }\r\n } as const;\r\n\r\n // ============================================\r\n // COMPONENT PROPS\r\n // ============================================\r\n\r\n let {\r\n id,\r\n open = $bindable(false),\r\n snapPoints = [0.9],\r\n defaultSnapPoint = 0,\r\n containerRef = null,\r\n scaleTargetRef = null,\r\n constrainToContainer = false,\r\n scaleBackground = true,\r\n dragToClose = true,\r\n closeOnBackdrop = true,\r\n showCloseButton = false,\r\n syncThemeColor = true,\r\n preventPullToRefresh = true,\r\n closeOnNavigateBack = true,\r\n onClose,\r\n onOpenChange,\r\n children,\r\n header\r\n }: BottomSheetProps = $props();\r\n\r\n // ============================================\r\n // INTERNAL STATE\r\n // ============================================\r\n\r\n // DOM references\r\n let sheetRef = $state<HTMLElement | null>(null);\r\n let contentRef = $state<HTMLElement | null>(null);\r\n let backdropRef = $state<HTMLElement | null>(null);\r\n\r\n // Sheet state\r\n let isVisible = $state(false);\r\n let isClosing = $state(false);\r\n let isDragging = $state(false);\r\n // Plain (NOT $state): written on every drag move (can be ~120/s) but never read\r\n // reactively (no template / $derived / $effect depends on it). Keeping it out of the\r\n // reactivity graph avoids a signal write per pointer event on high-refresh displays.\r\n // During a compositor animation it holds the resting target; the true on-screen value\r\n // is read from the compositor when a gesture interrupts (readLiveTranslateY).\r\n let currentTranslateY = 0;\r\n let sheetHeight = $state(0);\r\n\r\n // Compositor spring engine — programmatic motion (open/close/snap/settle) plays as Web\r\n // Animations on the compositor thread (see animateTo). currentTranslateY holds the live\r\n // RESTING position; while an animation runs we read the true on-screen value straight\r\n // from the compositor (readLiveTranslateY), so grabbing the sheet mid-flight never jumps.\r\n let sheetAnim: Animation | null = null; // sheet translate\r\n let backdropAnim: Animation | null = null; // backdrop opacity (kept in sync)\r\n let scaleAnim: Animation | null = null; // scale-target scale/translate/radius (in sync)\r\n let scaleTargetPrimed = false; // static scale-target styles set once per open cycle\r\n\r\n // iOS Safari status-bar tint. The full-screen backdrop darkens the area behind the status\r\n // bar; with no page-level <meta name=\"theme-color\"> Safari re-samples it and flips the bar\r\n // dark (losing the immersive look). When the page declares none we add one (= page\r\n // background) for the sheet's open lifetime and remove it on close; a page that sets its own\r\n // theme-color is left alone — Safari honors it, so the backdrop can't flip it.\r\n let themeColorMeta: HTMLMetaElement | null = null;\r\n\r\n // Whether THIS sheet currently holds the shared page-root overscroll guard (see the module\r\n // script). Kept per-instance so acquire/release stay balanced — restore is called from both\r\n // the close completion and onDestroy, and must be a no-op the second time.\r\n let heldOverscrollGuard = false;\r\n\r\n // Drag tracking\r\n let dragState = {\r\n startY: 0,\r\n startX: 0,\r\n lastY: 0,\r\n currentY: 0,\r\n deltaY: 0,\r\n startSnapPoint: 0,\r\n activePointerId: null as number | null,\r\n isTouchActive: false,\r\n shouldPreventScroll: false,\r\n history: [] as { y: number; time: number }[],\r\n // Two-phase gesture recognition for interactive elements\r\n isPending: false, // Tracking started but not yet committed to drag\r\n isInteractiveStart: false, // Gesture began on an interactive element\r\n committed: false, // Gesture has been resolved as a drag\r\n inScrollable: false // Gesture began inside a [data-bottom-sheet-scroll] area\r\n };\r\n\r\n // Scroll state\r\n let scrollableElement: HTMLElement | null = null;\r\n let isScrolledToTop = true;\r\n\r\n // Backdrop dismiss: the pointerId of a press that STARTED on the backdrop. A genuine\r\n // dismiss is a full press+release on the backdrop; tracking the origin lets us ignore\r\n // the stray pointerup that lands on a freshly-mounted backdrop when a press begun\r\n // elsewhere (e.g. a long-press on a button that opened the sheet) is released over it.\r\n let backdropPressPointerId: number | null = null;\r\n\r\n // Dimensions\r\n let windowHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 800);\r\n let containerHeight = $state(0);\r\n let containerResizeObserver: ResizeObserver | null = null;\r\n let prevOpen: boolean | null = null;\r\n\r\n // ============================================\r\n // COMPUTED VALUES\r\n // ============================================\r\n\r\n const r = $derived(bottomSheetRecipe({constrained: constrainToContainer, dragging: isDragging}));\r\n\r\n const scaleTarget = $derived(scaleTargetRef ?? containerRef);\r\n const effectiveHeight = $derived(\r\n constrainToContainer && containerRef ? containerHeight : windowHeight\r\n );\r\n const maxSnapPoint = $derived(Math.max(...snapPoints));\r\n const maxSheetHeight = $derived(maxSnapPoint * effectiveHeight);\r\n const zIndex = $derived(bottomSheetStore.getZIndex(id));\r\n const currentSnapPoint = $derived(snapPoints[defaultSnapPoint] ?? 0.5);\r\n const allSnapPoints = $derived([0, ...snapPoints].sort((a, b) => a - b));\r\n\r\n // ============================================\r\n // LIFECYCLE\r\n // ============================================\r\n\r\n // Browser Back closes the sheet, so a sheet opened over a Dialog takes the Back press\r\n // instead of letting it close the dialog underneath (history entries are LIFO, so this\r\n // resolves \"topmost wins\" on its own — but only once every modal pushes one).\r\n // Setting `open = false` (rather than doClose()) keeps the single close path in the\r\n // `open` effect; the hook's teardown then fires at the END of the close spring, where\r\n // `poppedByBack` correctly suppresses the compensating history.back().\r\n closeOnBackNavigation({\r\n enabled: () => closeOnNavigateBack,\r\n isOpen: () => open,\r\n close: () => { open = false; },\r\n });\r\n\r\n onMount(() => {\r\n bottomSheetStore.register(id, {snapPoints, defaultSnapPoint, closeOnBackdrop});\r\n window.addEventListener('resize', handleWindowResize);\r\n\r\n return () => {\r\n bottomSheetStore.unregister(id);\r\n window.removeEventListener('resize', handleWindowResize);\r\n };\r\n });\r\n\r\n onDestroy(() => {\r\n containerResizeObserver?.disconnect();\r\n cancelAnimations();\r\n restoreThemeColor();\r\n restoreOverscrollGuard();\r\n });\r\n\r\n // Motion is driven by direct writes (drag) and Web Animations (open/close/snap), so keep\r\n // the recipe's CSS `transition` OFF on these elements — otherwise the CSS transition would\r\n // fight each per-move drag write and smear it. (WAAPI animations are independent of the\r\n // `transition` property, so turning it off here does not affect them.)\r\n $effect(() => {\r\n if (sheetRef) sheetRef.style.transition = 'none';\r\n if (backdropRef) backdropRef.style.transition = 'none';\r\n });\r\n\r\n // Watch containerRef for constrained mode\r\n $effect(() => {\r\n if (!containerRef || !constrainToContainer) return;\r\n\r\n containerHeight = containerRef.clientHeight;\r\n containerResizeObserver?.disconnect();\r\n\r\n containerResizeObserver = new ResizeObserver(() => {\r\n containerHeight = containerRef!.clientHeight;\r\n if (isVisible && !isDragging && sheetRef) {\r\n sheetHeight = sheetRef.offsetHeight;\r\n }\r\n });\r\n containerResizeObserver.observe(containerRef);\r\n\r\n return () => {\r\n containerResizeObserver?.disconnect();\r\n containerResizeObserver = null;\r\n };\r\n });\r\n\r\n // Watch open prop changes\r\n $effect(() => {\r\n const shouldOpen = open;\r\n\r\n if (prevOpen === null) {\r\n prevOpen = shouldOpen;\r\n if (shouldOpen) doOpen();\r\n return;\r\n }\r\n\r\n if (shouldOpen && !prevOpen && !isVisible) {\r\n prevOpen = true;\r\n doOpen();\r\n } else if (!shouldOpen && prevOpen && isVisible && !isClosing) {\r\n prevOpen = false;\r\n doClose();\r\n }\r\n });\r\n\r\n // NOTE: The scrollable region ([data-bottom-sheet-scroll]) is resolved lazily\r\n // at gesture-start from the event target (see getScrollableFromTarget), NOT via\r\n // a one-shot querySelector here. A single querySelector at open-time cannot see\r\n // content that is mounted lazily AFTER the sheet opens (e.g. async-loaded lists),\r\n // which left scrollableElement null and broke drag/scroll arbitration inside it.\r\n\r\n // ============================================\r\n // CORE FUNCTIONS\r\n // ============================================\r\n\r\n function handleWindowResize() {\r\n if (isVisible && !isDragging) {\r\n windowHeight = window.innerHeight;\r\n if (sheetRef) sheetHeight = sheetRef.offsetHeight;\r\n }\r\n }\r\n\r\n function recalculateDimensions() {\r\n if (typeof window !== 'undefined') windowHeight = window.innerHeight;\r\n if (containerRef && constrainToContainer) containerHeight = containerRef.clientHeight;\r\n if (sheetRef) sheetHeight = sheetRef.offsetHeight;\r\n }\r\n\r\n // ============================================\r\n // COMPOSITOR SPRING ENGINE (Web Animations API)\r\n // ============================================\r\n // Open / close / snap / settle play as Web Animations on the COMPOSITOR thread, so they\r\n // hold the display's native refresh rate (e.g. 120Hz) even when the main thread is busy —\r\n // the failure mode of a per-frame rAF loop, which must be scheduled on main every frame\r\n // and drops frames under contention. We simulate the damped spring ONCE per gesture\r\n // (seeded with the finger's release velocity) to get a position trajectory, then hand it\r\n // to the compositor as keyframes: NO per-frame JavaScript, store write, or layout during\r\n // the animation. Drag stays on direct per-input writes (renderPosition) — it must track\r\n // the finger 1:1, which no pre-baked animation can do.\r\n\r\n const prefersReducedMotion = () =>\r\n typeof window !== 'undefined' &&\r\n !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;\r\n\r\n /** Convert (response, dampingRatio) to spring stiffness/damping (unit mass). */\r\n function springConstants(response: number, damping: number) {\r\n const omega = (2 * Math.PI) / response; // natural angular frequency\r\n return {k: omega * omega, c: 2 * damping * omega};\r\n }\r\n\r\n // ── Position → visual mappings. Shared by immediate drag writes (renderPosition) and by\r\n // keyframe generation, so a compositor animation and a live drag paint identically. ──\r\n\r\n function backdropOpacityFor(translateY: number): number {\r\n const progress = Math.max(0, Math.min(maxSnapPoint, (sheetHeight - translateY) / effectiveHeight));\r\n return Math.max(0, Math.min(CONFIG.backdrop.maxOpacity, progress * CONFIG.backdrop.maxOpacity));\r\n }\r\n\r\n function scalePropsFor(translateY: number) {\r\n const p = Math.max(0, Math.min(1, (sheetHeight - translateY) / effectiveHeight));\r\n const {factor, borderRadius, translateY: ty} = CONFIG.scale;\r\n return {\r\n scale: String(1 - factor * p),\r\n translate: `0 ${(ty * p).toFixed(2)}px`,\r\n borderRadius: `${(borderRadius * p).toFixed(2)}px`\r\n };\r\n }\r\n\r\n /** Set the scale target's static (non-animated) styles once per open cycle. */\r\n function primeScaleTarget() {\r\n if (scaleTargetPrimed || !scaleTarget || !scaleBackground) return;\r\n scaleTarget.style.transition = 'none';\r\n scaleTarget.style.transformOrigin = CONFIG.scale.origin;\r\n scaleTarget.style.overflow = 'hidden';\r\n scaleTarget.style.willChange = 'scale, translate, border-radius';\r\n scaleTargetPrimed = true;\r\n }\r\n\r\n /** Write the whole visual state for a given translateY. Used by DRAG and immediate sets. */\r\n function renderPosition(translateY: number) {\r\n if (sheetRef) sheetRef.style.translate = `0 ${translateY}px`;\r\n const rawProgress = (sheetHeight - translateY) / effectiveHeight;\r\n const progress = Math.max(0, Math.min(maxSnapPoint, rawProgress));\r\n updateBackdrop(progress);\r\n updateScaleTarget(progress);\r\n bottomSheetStore.updateProgress(id, Math.max(0, Math.min(1, rawProgress)));\r\n }\r\n\r\n /** Read the sheet's LIVE translateY — reflects a running compositor animation. */\r\n function readLiveTranslateY(): number | null {\r\n if (!sheetRef) return null;\r\n const t = getComputedStyle(sheetRef).getPropertyValue('translate');\r\n if (!t || t === 'none') return null;\r\n const parts = t.trim().split(/\\s+/);\r\n const y = parts.length > 1 ? parseFloat(parts[1]!) : 0;\r\n return Number.isFinite(y) ? y : null;\r\n }\r\n\r\n /** Cancel any in-flight compositor animations (sheet + backdrop + scale). */\r\n function cancelAnimations() {\r\n sheetAnim?.cancel();\r\n backdropAnim?.cancel();\r\n scaleAnim?.cancel();\r\n sheetAnim = backdropAnim = scaleAnim = null;\r\n }\r\n\r\n /**\r\n * Freeze the sheet at its LIVE on-screen position and stop the animation, so a fresh\r\n * gesture anchors to exactly what's rendered — no jump. Called when the user grabs the\r\n * sheet mid-flight. (.cancel() reverts fill:none animations to their base, which is the\r\n * settled target — so we pin the live value to inline styles FIRST, then cancel.)\r\n */\r\n function interruptAnimation() {\r\n if (sheetAnim || backdropAnim || scaleAnim) {\r\n const liveY = readLiveTranslateY();\r\n if (liveY !== null) {\r\n currentTranslateY = liveY;\r\n renderPosition(liveY); // pin translate + backdrop + scale to the live value\r\n }\r\n cancelAnimations();\r\n }\r\n isClosing = false;\r\n }\r\n\r\n /** Snap all visuals to a resting target — the base the fill:none animations settle onto. */\r\n function applyFinal(target: number) {\r\n currentTranslateY = target;\r\n renderPosition(target);\r\n }\r\n\r\n /**\r\n * Simulate a damped spring from startY → targetY seeded with v0 (px/s), returning the\r\n * position trajectory (evenly spaced in time) and its duration (ms). For close/hide we\r\n * stop at the FIRST arrival at the target (on the way offscreen) so there is no tail.\r\n */\r\n function simulateSpring(\r\n startY: number,\r\n targetY: number,\r\n v0: number,\r\n spring: {response: number; damping: number},\r\n hideOnArrival: boolean\r\n ): {ys: number[]; duration: number} {\r\n const {restDistance, restVelocity} = CONFIG.animation;\r\n const {k, c} = springConstants(spring.response, spring.damping);\r\n const dt = 1 / 90; // keyframe sampling step; compositor interpolates between\r\n const subSteps = 2; // physics sub-steps per sample (stability at high stiffness)\r\n const h = dt / subSteps;\r\n const maxSteps = Math.ceil(1.5 / dt); // safety cap (~1.5s)\r\n\r\n let x = startY;\r\n let v = v0;\r\n const ys: number[] = [x];\r\n for (let i = 0; i < maxSteps; i++) {\r\n for (let s = 0; s < subSteps; s++) {\r\n const a = -k * (x - targetY) - c * v;\r\n v += a * h;\r\n x += v * h;\r\n }\r\n if (hideOnArrival && x >= targetY) {\r\n ys.push(targetY); // reached offscreen — stop before any rebound\r\n break;\r\n }\r\n ys.push(x);\r\n if (!hideOnArrival && Math.abs(x - targetY) < restDistance && Math.abs(v) < restVelocity) {\r\n ys[ys.length - 1] = targetY;\r\n break;\r\n }\r\n }\r\n return {ys, duration: (ys.length - 1) * dt * 1000};\r\n }\r\n\r\n /**\r\n * Spring the sheet toward `target`, seeded with `initialVelocity` (px/s) so a flick\r\n * continues seamlessly. Plays on the COMPOSITOR via the Web Animations API. If a spring\r\n * is already in flight we read its LIVE position and re-simulate from there, so retargets\r\n * (and mid-flight direction changes) never jump. The sheet's translate, the backdrop's\r\n * opacity, and the scale target all animate from ONE simulation with identical timing, so\r\n * they stay locked together on the compositor with zero per-frame JS.\r\n */\r\n function animateTo(\r\n target: number,\r\n spring: {response: number; damping: number},\r\n initialVelocity = 0,\r\n onComplete?: () => void,\r\n hideOnArrival = false\r\n ) {\r\n const running = !!(sheetAnim || backdropAnim || scaleAnim);\r\n const start = running ? (readLiveTranslateY() ?? currentTranslateY) : currentTranslateY;\r\n cancelAnimations();\r\n\r\n // Reduced motion, or a move too small to be worth animating → jump straight there.\r\n if (prefersReducedMotion()) {\r\n applyFinal(target);\r\n onComplete?.();\r\n return;\r\n }\r\n\r\n const {ys, duration} = simulateSpring(start, target, initialVelocity, spring, hideOnArrival);\r\n if (duration < 8 || ys.length < 2) {\r\n applyFinal(target);\r\n onComplete?.();\r\n return;\r\n }\r\n\r\n if (scaleTarget && scaleBackground) primeScaleTarget();\r\n\r\n // Bases = final values, so when the fill:none animations end the elements hold `target`\r\n // (no revert-to-start flash). The animations override this from t=0 with keyframe[0]=start.\r\n applyFinal(target);\r\n\r\n const N = ys.length;\r\n const opts: KeyframeAnimationOptions = {duration, easing: 'linear', fill: 'none'};\r\n\r\n const sheetKf = ys.map((y, i) => ({translate: `0 ${y.toFixed(2)}px`, offset: i / (N - 1)}));\r\n sheetAnim = sheetRef ? sheetRef.animate(sheetKf, opts) : null;\r\n\r\n if (backdropRef) {\r\n const kf = ys.map((y, i) => ({opacity: String(backdropOpacityFor(y)), offset: i / (N - 1)}));\r\n backdropAnim = backdropRef.animate(kf, opts);\r\n }\r\n\r\n if (scaleTarget && scaleBackground) {\r\n const kf = ys.map((y, i) => ({...scalePropsFor(y), offset: i / (N - 1)}));\r\n scaleAnim = scaleTarget.animate(kf, opts);\r\n }\r\n\r\n // Finalize on natural completion only. .cancel() (retarget / grab) fires oncancel, not\r\n // onfinish, so a superseded animation never runs the callback.\r\n if (sheetAnim) {\r\n sheetAnim.onfinish = () => {\r\n sheetAnim = backdropAnim = scaleAnim = null;\r\n onComplete?.();\r\n };\r\n } else {\r\n applyFinal(target);\r\n onComplete?.();\r\n }\r\n }\r\n\r\n function doOpen() {\r\n // Refresh viewport/container dimensions BEFORE the sheet renders, so its\r\n // height (maxSheetHeight) is derived from current values. While the sheet is\r\n // closed, windowHeight is not tracked (handleWindowResize is gated on\r\n // isVisible), so a resize between opens would otherwise leave the first\r\n // render using a stale height — measured sheetHeight then mismatches the\r\n // fresh effectiveHeight, shifting the sheet off the bottom edge until the\r\n // next open. Doing this before isVisible keeps them in sync.\r\n if (typeof window !== 'undefined') windowHeight = window.innerHeight;\r\n if (containerRef && constrainToContainer) containerHeight = containerRef.clientHeight;\r\n\r\n isVisible = true;\r\n isClosing = false;\r\n backdropPressPointerId = null; // fresh open — ignore any stale backdrop press id\r\n applyThemeColor(); // hold the iOS status bar at the page color before the backdrop darkens it\r\n applyOverscrollGuard(); // stop the page pull-to-refresh from hijacking a slow drag off an interactive element\r\n bottomSheetStore.open(id);\r\n\r\n requestAnimationFrame(() => {\r\n // sheetRef is bound by this rAF tick.\r\n recalculateDimensions();\r\n // Start fully hidden (matches the sheet's initial CSS translate of 100%),\r\n // then spring up to the target snap point.\r\n currentTranslateY = sheetHeight;\r\n renderPosition(sheetHeight);\r\n const target = sheetHeight - currentSnapPoint * effectiveHeight;\r\n animateTo(target, CONFIG.animation.open, 0);\r\n });\r\n }\r\n\r\n function doClose() {\r\n if (isClosing) return;\r\n animateSheetClose(0);\r\n }\r\n\r\n /** Spring the sheet fully down and finalize. `initialVelocity` (px/s) carries a flick-to-dismiss. */\r\n function animateSheetClose(initialVelocity: number) {\r\n isClosing = true;\r\n animateTo(sheetHeight, CONFIG.animation.close, initialVelocity, () => {\r\n isVisible = false;\r\n isClosing = false;\r\n bottomSheetStore.close(id);\r\n prevOpen = false;\r\n open = false;\r\n resetScaleTarget();\r\n restoreThemeColor();\r\n restoreOverscrollGuard();\r\n onClose?.();\r\n onOpenChange?.(false);\r\n }, true); // hideOnArrival — finalize as soon as it's offscreen, no spring tail\r\n }\r\n\r\n /**\r\n * Settle after a drag release: resolve the target snap point, then spring to it\r\n * carrying the finger's release velocity so the hand-off is seamless.\r\n */\r\n function settleFromRelease() {\r\n const nearestSnap = determineSnapPoint();\r\n const releaseVelocity = calculateVelocity() * 1000; // px/ms → px/s (down = positive)\r\n\r\n if (nearestSnap === 0 && dragToClose) {\r\n animateSheetClose(releaseVelocity);\r\n return;\r\n }\r\n\r\n const snap = nearestSnap > 0 ? nearestSnap : dragState.startSnapPoint;\r\n const target = sheetHeight - snap * effectiveHeight;\r\n animateTo(target, CONFIG.animation.snap, releaseVelocity, () => {\r\n if (!open) {\r\n prevOpen = true;\r\n open = true;\r\n onOpenChange?.(true);\r\n }\r\n });\r\n }\r\n\r\n // ============================================\r\n // VISUAL UPDATES\r\n // ============================================\r\n\r\n function updateBackdrop(progress: number) {\r\n if (!backdropRef) return;\r\n const opacity = Math.max(0, Math.min(CONFIG.backdrop.maxOpacity, progress * CONFIG.backdrop.maxOpacity));\r\n backdropRef.style.opacity = String(opacity);\r\n }\r\n\r\n function updateScaleTarget(progress: number) {\r\n if (!scaleTarget || !scaleBackground) return;\r\n\r\n // Static props are set ONCE per open cycle (see primeScaleTarget) — re-writing\r\n // transition / transform-origin / overflow / will-change every frame churns the\r\n // compositor layer and blows the frame budget on 120Hz displays.\r\n primeScaleTarget();\r\n\r\n const p = Math.max(0, Math.min(1, progress));\r\n const {factor, borderRadius, translateY} = CONFIG.scale;\r\n\r\n // Per-frame: scale + translate are GPU-composited (cheap). Quantize border-radius to\r\n // whole px so it only actually repaints ~a dozen times across the gesture, not 120×/s.\r\n scaleTarget.style.scale = String(1 - factor * p);\r\n scaleTarget.style.translate = `0 ${(translateY * p).toFixed(2)}px`;\r\n scaleTarget.style.borderRadius = `${Math.round(borderRadius * p)}px`;\r\n }\r\n\r\n function resetScaleTarget() {\r\n scaleTargetPrimed = false;\r\n if (!scaleTarget || !scaleBackground) return;\r\n\r\n scaleTarget.style.scale = '';\r\n scaleTarget.style.translate = '';\r\n scaleTarget.style.borderRadius = '';\r\n scaleTarget.style.transformOrigin = '';\r\n scaleTarget.style.overflow = '';\r\n scaleTarget.style.transition = '';\r\n scaleTarget.style.willChange = '';\r\n }\r\n\r\n // ============================================\r\n // BROWSER CHROME (iOS status-bar tint)\r\n // ============================================\r\n\r\n function isTransparentColor(color: string): boolean {\r\n return !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)';\r\n }\r\n\r\n /** First opaque background up the page root — what iOS Safari samples for the status bar. */\r\n function resolvePageBackgroundColor(): string {\r\n if (typeof document === 'undefined') return '#ffffff';\r\n for (const el of [document.body, document.documentElement]) {\r\n if (!el) continue;\r\n const bg = getComputedStyle(el).backgroundColor;\r\n if (!isTransparentColor(bg)) return bg;\r\n }\r\n return '#ffffff';\r\n }\r\n\r\n /**\r\n * Keep the iOS Safari status bar immersive while the sheet is open. Acts ONLY when the page\r\n * declares no <meta name=\"theme-color\"> — the case Safari resolves by sampling the now-darkened\r\n * backdrop and flipping the bar dark: we add one pinned to the page background (theme-aware,\r\n * so it also tracks dark mode). A page that sets its own theme-color owns its status bar and\r\n * is left untouched. restoreThemeColor removes ours on close.\r\n */\r\n function applyThemeColor() {\r\n if (!syncThemeColor || typeof document === 'undefined' || themeColorMeta) return;\r\n if (document.querySelector('meta[name=\"theme-color\"]')) return;\r\n\r\n const meta = document.createElement('meta');\r\n meta.name = 'theme-color';\r\n meta.setAttribute('content', resolvePageBackgroundColor());\r\n document.head.appendChild(meta);\r\n themeColorMeta = meta;\r\n }\r\n\r\n /** Remove the theme-color tag we added on open (no-op if we never added one). */\r\n function restoreThemeColor() {\r\n themeColorMeta?.remove();\r\n themeColorMeta = null;\r\n }\r\n\r\n /**\r\n * Guard the page against native pull-to-refresh while this sheet is open (see the module\r\n * script). Balanced + idempotent per instance so the shared reference count stays correct\r\n * even though restore fires from both the close completion and onDestroy.\r\n */\r\n function applyOverscrollGuard() {\r\n if (!preventPullToRefresh || heldOverscrollGuard) return;\r\n heldOverscrollGuard = true;\r\n acquireOverscrollGuard();\r\n }\r\n\r\n /** Release this sheet's hold on the page overscroll guard (no-op if not held). */\r\n function restoreOverscrollGuard() {\r\n if (!heldOverscrollGuard) return;\r\n heldOverscrollGuard = false;\r\n releaseOverscrollGuard();\r\n }\r\n\r\n // ============================================\r\n // GESTURE UTILITIES\r\n // ============================================\r\n\r\n function pushHistory(y: number) {\r\n const now = performance.now();\r\n dragState.history = dragState.history.filter(p => now - p.time <= 100);\r\n dragState.history.push({y, time: now});\r\n }\r\n\r\n function clearHistory() {\r\n dragState.history = [];\r\n }\r\n\r\n function calculateVelocity(): number {\r\n const now = performance.now();\r\n const recent = dragState.history.filter(p => now - p.time <= 100);\r\n\r\n if (recent.length < 2) return 0;\r\n\r\n const first = recent[0];\r\n const last = recent[recent.length - 1];\r\n if (!first || !last) return 0;\r\n const dt = last.time - first.time;\r\n\r\n return dt > 0 ? (last.y - first.y) / dt : 0;\r\n }\r\n\r\n function clampAboveMaxSnap(translateY: number): number {\r\n // The sheet is only as tall as its MAX snap point, with its bottom pinned to the\r\n // screen bottom (translateY 0 == fully open to max). Letting translateY go below\r\n // that (negative) would lift the sheet's bottom edge off the screen and reveal the\r\n // background in the gap beneath it — which looks broken. So pin it: you cannot drag\r\n // the sheet frame above its largest detent. This is also what native iOS does — the\r\n // frame is firm at the top detent; only inner scroll content overscrolls.\r\n const topLimit = sheetHeight - maxSnapPoint * effectiveHeight;\r\n return translateY < topLimit ? topLimit : translateY;\r\n }\r\n\r\n function applyLockedDragResistance(rawTranslateY: number): number {\r\n // Base translateY when at start snap point: sheetHeight - (startSnapPoint * effectiveHeight)\r\n const baseTranslateY = sheetHeight - (dragState.startSnapPoint * effectiveHeight);\r\n const dragDistance = rawTranslateY - baseTranslateY;\r\n\r\n if (dragDistance > 0) {\r\n const {lockedDragResistance, maxLockedDrag} = CONFIG.gesture;\r\n const resistedDrag = Math.min(dragDistance * lockedDragResistance, maxLockedDrag);\r\n return baseTranslateY + resistedDrag;\r\n }\r\n return rawTranslateY;\r\n }\r\n\r\n function findNearestSnapPoint(translateY: number, velocityInfluence = 0): number {\r\n // Calculate progress: translateY = sheetHeight - (progress * effectiveHeight)\r\n // So: progress = (sheetHeight - translateY) / effectiveHeight\r\n const currentProgress = (sheetHeight - translateY) / effectiveHeight;\r\n const targetProgress = currentProgress + velocityInfluence;\r\n\r\n return allSnapPoints.reduce((nearest, sp) =>\r\n Math.abs(targetProgress - sp) < Math.abs(targetProgress - nearest) ? sp : nearest\r\n , allSnapPoints[0] ?? 0);\r\n }\r\n\r\n function determineSnapPoint(): number {\r\n // Calculate progress: translateY = sheetHeight - (progress * effectiveHeight)\r\n // So: progress = (sheetHeight - translateY) / effectiveHeight\r\n const currentProgress = (sheetHeight - currentTranslateY) / effectiveHeight;\r\n const velocity = calculateVelocity();\r\n\r\n if (!dragToClose) {\r\n return dragState.startSnapPoint;\r\n }\r\n\r\n const {closeVelocityThreshold, closeProgressThreshold, velocityInfluence} = CONFIG.gesture;\r\n\r\n // Fast swipe handling\r\n if (Math.abs(velocity) > closeVelocityThreshold) {\r\n if (velocity > 0) {\r\n // Swipe down - close or go to lower snap\r\n const lowerSnaps = allSnapPoints.filter(sp => sp < dragState.startSnapPoint);\r\n const lowest = lowerSnaps[lowerSnaps.length - 1];\r\n if (lowest !== undefined && currentProgress >= lowest * 0.5) {\r\n return lowest;\r\n }\r\n return 0;\r\n } else {\r\n // Swipe up - go to higher snap\r\n const higherSnaps = allSnapPoints.filter(sp => sp > currentProgress);\r\n return higherSnaps[0] ?? Math.max(...snapPoints);\r\n }\r\n }\r\n\r\n // Check close threshold\r\n const progressDrop = dragState.startSnapPoint - currentProgress;\r\n if (progressDrop > closeProgressThreshold) {\r\n return 0;\r\n }\r\n\r\n // Velocity is in px/ms, convert to progress units (divide by effectiveHeight, multiply by 1000 for scale)\r\n const velocityInProgressUnits = -velocity / effectiveHeight * 1000;\r\n return findNearestSnapPoint(currentTranslateY, velocityInProgressUnits * velocityInfluence);\r\n }\r\n\r\n // ============================================\r\n // TOUCH EVENT HANDLERS\r\n // ============================================\r\n\r\n function isInDraggableArea(target: HTMLElement): boolean {\r\n return !!(target.closest('[data-bottom-sheet-handle]'));\r\n }\r\n\r\n function isInteractive(target: HTMLElement): boolean {\r\n return !!target.closest('button, a, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');\r\n }\r\n\r\n function shouldIgnoreDrag(target: HTMLElement): boolean {\r\n return !!target.closest('[data-bottom-sheet-no-drag]');\r\n }\r\n\r\n /**\r\n * Resolve the scrollable region ([data-bottom-sheet-scroll]) that contains the\r\n * gesture target. Resolved per-gesture (not once at open-time) so that content\r\n * mounted lazily after the sheet opened — and nested/multiple scroll regions —\r\n * are detected correctly. Returns the nearest scrollable ancestor, or null.\r\n */\r\n function getScrollableFromTarget(target: HTMLElement): HTMLElement | null {\r\n return target.closest<HTMLElement>('[data-bottom-sheet-scroll]');\r\n }\r\n\r\n /**\r\n * Install a one-shot capture-phase click listener to suppress the ghost click\r\n * that fires after a committed drag from an interactive element.\r\n */\r\n function suppressNextClick() {\r\n if (!sheetRef) return;\r\n const handler = (e: Event) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n sheetRef?.removeEventListener('click', handler, true);\r\n };\r\n sheetRef.addEventListener('click', handler, true);\r\n // Safety: auto-remove after a short delay if click never fires\r\n setTimeout(() => sheetRef?.removeEventListener('click', handler, true), 300);\r\n }\r\n\r\n /**\r\n * Reset all pending/two-phase gesture state fields.\r\n */\r\n function resetPendingState() {\r\n dragState.isPending = false;\r\n dragState.isInteractiveStart = false;\r\n dragState.committed = false;\r\n dragState.inScrollable = false;\r\n }\r\n\r\n /**\r\n * Initialize common drag tracking fields shared by both touch and pointer paths.\r\n */\r\n function initDragTracking(clientX: number, clientY: number) {\r\n dragState.startX = clientX;\r\n dragState.startY = clientY;\r\n dragState.lastY = clientY;\r\n dragState.currentY = currentTranslateY;\r\n dragState.deltaY = 0;\r\n dragState.startSnapPoint = Math.round((sheetHeight - currentTranslateY) / effectiveHeight * 100) / 100;\r\n clearHistory();\r\n pushHistory(clientY);\r\n }\r\n\r\n /**\r\n * Commit to dragging: transition from pending state to active drag.\r\n * Re-anchors startY/currentY to the current position to prevent visual jump.\r\n */\r\n function commitToDrag(clientY: number) {\r\n dragState.isPending = false;\r\n dragState.committed = true;\r\n // Re-anchor to current position so sheet doesn't jump\r\n dragState.startY = clientY;\r\n dragState.currentY = currentTranslateY;\r\n clearHistory();\r\n pushHistory(clientY);\r\n\r\n interruptAnimation();\r\n if (scaleTarget) scaleTarget.style.transition = 'none';\r\n isDragging = true;\r\n }\r\n\r\n /**\r\n * Check if the pending gesture should be resolved.\r\n * Returns: 'drag' if vertical threshold exceeded and should drag sheet,\r\n * 'abort' if horizontal wins or user wants to scroll content,\r\n * 'pending' if undecided.\r\n */\r\n function resolvePendingGesture(clientX: number, clientY: number): 'drag' | 'abort' | 'pending' {\r\n const {dragActivationThreshold, horizontalDeadZone} = CONFIG.gesture;\r\n const absX = Math.abs(clientX - dragState.startX);\r\n const absY = Math.abs(clientY - dragState.startY);\r\n const deltaY = clientY - dragState.startY; // positive = finger moving down\r\n\r\n // Horizontal threshold exceeded — let native behavior handle it (slider, scroll, etc.)\r\n if (absX >= dragActivationThreshold) {\r\n return 'abort';\r\n }\r\n\r\n // Vertical threshold exceeded and angle is more vertical than horizontal\r\n if (absY >= dragActivationThreshold && absY > absX * horizontalDeadZone) {\r\n // Inside a scrollable area: only commit to drag if pulling DOWN from top (dismiss gesture).\r\n // If swiping UP (to scroll content down), abort and let native scroll work.\r\n if (dragState.inScrollable) {\r\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\r\n if (deltaY > 0 && isScrolledToTop) {\r\n // Pulling down from top → drag sheet (dismiss)\r\n return 'drag';\r\n }\r\n // Swiping up, or not at top → let native scroll handle it\r\n return 'abort';\r\n }\r\n return 'drag';\r\n }\r\n\r\n return 'pending';\r\n }\r\n\r\n function handleTouchStart(e: TouchEvent) {\r\n if (dragState.isTouchActive) return;\r\n\r\n const target = e.target as HTMLElement;\r\n const inDraggableArea = isInDraggableArea(target);\r\n // Resolve scrollable region from the actual target so lazily-mounted content is detected.\r\n scrollableElement = getScrollableFromTarget(target);\r\n const inScrollable = !!scrollableElement;\r\n const isInteractiveTarget = isInteractive(target);\r\n\r\n // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all\r\n if (shouldIgnoreDrag(target) && !inDraggableArea) {\r\n return;\r\n }\r\n\r\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\r\n\r\n if (inScrollable && !isScrolledToTop && !inDraggableArea) {\r\n dragState.shouldPreventScroll = false;\r\n return;\r\n }\r\n\r\n // Initialize tracking for all cases\r\n const touch = e.touches[0];\r\n if (!touch) return;\r\n dragState.isTouchActive = true;\r\n initDragTracking(touch.clientX, touch.clientY);\r\n\r\n // Interactive element (button, input, slider, etc.) NOT in draggable area:\r\n // Enter pending state — don't commit to drag yet, let threshold decide\r\n if (isInteractiveTarget && !inDraggableArea) {\r\n dragState.isPending = true;\r\n dragState.isInteractiveStart = true;\r\n dragState.committed = false;\r\n dragState.inScrollable = inScrollable;\r\n dragState.shouldPreventScroll = true; // Will be used if we commit\r\n\r\n // Do NOT set isDragging or interrupt the animation yet — allow native\r\n // touch behavior until the gesture commits, and don't call e.preventDefault().\r\n return;\r\n }\r\n\r\n // Non-interactive element: immediately commit to dragging (existing behavior)\r\n dragState.isPending = false;\r\n dragState.isInteractiveStart = false;\r\n dragState.committed = true;\r\n dragState.shouldPreventScroll = !(inScrollable && isScrolledToTop && !inDraggableArea);\r\n\r\n interruptAnimation();\r\n if (scaleTarget) scaleTarget.style.transition = 'none';\r\n isDragging = true;\r\n }\r\n\r\n function handleTouchMove(e: TouchEvent) {\r\n if (!dragState.isTouchActive) return;\r\n\r\n const touch = e.touches[0];\r\n if (!touch) return;\r\n const clientY = touch.clientY;\r\n const clientX = touch.clientX;\r\n\r\n // ── Two-phase: resolve pending gesture from interactive element ──\r\n if (dragState.isPending) {\r\n pushHistory(clientY);\r\n const result = resolvePendingGesture(clientX, clientY);\r\n\r\n if (result === 'drag') {\r\n // Vertical intent wins → commit to drag\r\n commitToDrag(clientY);\r\n dragState.shouldPreventScroll = true;\r\n e.preventDefault();\r\n return; // First real drag frame happens on next move\r\n }\r\n if (result === 'abort') {\r\n // Horizontal intent wins → abort, let native behavior (slider, etc.)\r\n dragState.isPending = false;\r\n dragState.committed = false;\r\n dragState.isTouchActive = false;\r\n dragState.isInteractiveStart = false;\r\n return;\r\n }\r\n // Still pending (under the commit threshold). Claim a downward, vertical-leaning pull\r\n // NOW so iOS Safari can't start its native pull-to-refresh during this slow pre-commit\r\n // window — overscroll-behavior does NOT stop Safari's UA refresh; only preventDefault\r\n // does. Stay PENDING (the sheet doesn't start moving until the >=10px commit), but deny\r\n // the browser the gesture. Skip when a scroll region under the finger should scroll\r\n // instead (pulling down while not at its top), and skip horizontal-leaning moves so\r\n // native sliders keep working. Touch-only: desktop has no pull-to-refresh.\r\n const claimDx = Math.abs(clientX - dragState.startX);\r\n const claimDy = Math.abs(clientY - dragState.startY);\r\n if (clientY - dragState.startY > 0 && claimDy >= CONFIG.gesture.pullClaimThreshold && claimDy >= claimDx) {\r\n let claimGesture = true;\r\n if (dragState.inScrollable) {\r\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\r\n if (!isScrolledToTop) claimGesture = false; // let the content scroll natively\r\n }\r\n if (claimGesture && e.cancelable) e.preventDefault();\r\n }\r\n return;\r\n }\r\n\r\n // ── Normal drag logic (committed) ──\r\n const moveDelta = clientY - dragState.startY;\r\n dragState.deltaY = clientY - dragState.lastY;\r\n dragState.lastY = clientY;\r\n pushHistory(clientY);\r\n\r\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\r\n if (isScrolledToTop && moveDelta > 0) dragState.shouldPreventScroll = true;\r\n if (!dragState.shouldPreventScroll) return;\r\n\r\n e.preventDefault();\r\n\r\n let translateY = dragState.currentY + moveDelta;\r\n translateY = clampAboveMaxSnap(translateY);\r\n if (!dragToClose) translateY = applyLockedDragResistance(translateY);\r\n translateY = Math.min(translateY, sheetHeight);\r\n\r\n currentTranslateY = translateY;\r\n renderPosition(translateY);\r\n }\r\n\r\n function handleTouchEnd(e: TouchEvent) {\r\n if (e.touches.length > 0 || !dragState.isTouchActive) return;\r\n\r\n dragState.isTouchActive = false;\r\n\r\n // ── Two-phase: pending never resolved (tap) → let native click fire ──\r\n if (dragState.isPending) {\r\n resetPendingState();\r\n return;\r\n }\r\n\r\n // ── Committed drag from interactive element → suppress ghost click ──\r\n if (dragState.committed && dragState.isInteractiveStart) {\r\n suppressNextClick();\r\n }\r\n resetPendingState();\r\n\r\n if (!dragState.shouldPreventScroll) {\r\n isDragging = false;\r\n return;\r\n }\r\n\r\n isDragging = false;\r\n dragState.shouldPreventScroll = false;\r\n\r\n settleFromRelease();\r\n }\r\n\r\n // ============================================\r\n // POINTER EVENT HANDLERS (Desktop)\r\n // ============================================\r\n\r\n function handlePointerDown(e: PointerEvent) {\r\n if (e.pointerType === 'touch' || e.button !== 0 || dragState.activePointerId !== null) return;\r\n\r\n const target = e.target as HTMLElement;\r\n const inDraggableArea = isInDraggableArea(target);\r\n // Resolve scrollable region from the actual target so lazily-mounted content is detected.\r\n scrollableElement = getScrollableFromTarget(target);\r\n const inScrollable = !!scrollableElement;\r\n const isInteractiveTarget = isInteractive(target);\r\n const shouldIgnoreDragTarget = shouldIgnoreDrag(target);\r\n\r\n // data-bottom-sheet-no-drag: treat as fully default element — no tracking at all\r\n if (shouldIgnoreDragTarget && !inDraggableArea) return;\r\n\r\n if (scrollableElement) isScrolledToTop = scrollableElement.scrollTop <= 1;\r\n if (inScrollable && !isScrolledToTop && !inDraggableArea) return;\r\n\r\n dragState.activePointerId = e.pointerId;\r\n initDragTracking(e.clientX, e.clientY);\r\n\r\n // Interactive element NOT in draggable area:\r\n // Enter pending state — don't commit yet, preserve native focus/click\r\n if (isInteractiveTarget && !inDraggableArea) {\r\n dragState.isPending = true;\r\n dragState.isInteractiveStart = true;\r\n dragState.committed = false;\r\n dragState.inScrollable = inScrollable;\r\n\r\n // Do NOT call e.preventDefault() — preserve focus for inputs\r\n // Do NOT call setPointerCapture — allow native hover/click behavior\r\n // Do NOT set isDragging or disable transitions\r\n return;\r\n }\r\n\r\n // Non-interactive element: immediately commit to dragging\r\n dragState.isPending = false;\r\n dragState.isInteractiveStart = false;\r\n dragState.committed = true;\r\n\r\n interruptAnimation();\r\n if (scaleTarget) scaleTarget.style.transition = 'none';\r\n sheetRef?.setPointerCapture(e.pointerId);\r\n isDragging = true;\r\n e.preventDefault();\r\n }\r\n\r\n function handlePointerMove(e: PointerEvent) {\r\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\r\n if (!isDragging && !dragState.isPending) return;\r\n\r\n const clientY = e.clientY;\r\n const clientX = e.clientX;\r\n\r\n // ── Two-phase: resolve pending gesture from interactive element ──\r\n if (dragState.isPending) {\r\n pushHistory(clientY);\r\n const result = resolvePendingGesture(clientX, clientY);\r\n\r\n if (result === 'drag') {\r\n // Vertical intent wins → commit to drag\r\n commitToDrag(clientY);\r\n sheetRef?.setPointerCapture(e.pointerId);\r\n e.preventDefault();\r\n return; // First real drag frame happens on next move\r\n }\r\n if (result === 'abort') {\r\n // Horizontal intent wins → abort, let native behavior\r\n dragState.isPending = false;\r\n dragState.committed = false;\r\n dragState.activePointerId = null;\r\n dragState.isInteractiveStart = false;\r\n return;\r\n }\r\n // Still pending — do nothing\r\n return;\r\n }\r\n\r\n // ── Normal drag logic (committed) ──\r\n dragState.deltaY = clientY - dragState.lastY;\r\n dragState.lastY = clientY;\r\n pushHistory(clientY);\r\n\r\n let translateY = dragState.currentY + clientY - dragState.startY;\r\n translateY = clampAboveMaxSnap(translateY);\r\n if (!dragToClose) translateY = applyLockedDragResistance(translateY);\r\n translateY = Math.min(translateY, sheetHeight);\r\n\r\n currentTranslateY = translateY;\r\n renderPosition(translateY);\r\n\r\n e.preventDefault();\r\n }\r\n\r\n function handlePointerUp(e: PointerEvent) {\r\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\r\n\r\n try {\r\n sheetRef?.releasePointerCapture(e.pointerId);\r\n } catch {\r\n }\r\n dragState.activePointerId = null;\r\n\r\n // ── Two-phase: pending never resolved (tap) → let native click fire ──\r\n if (dragState.isPending) {\r\n resetPendingState();\r\n return;\r\n }\r\n\r\n // ── Committed drag from interactive element → suppress ghost click ──\r\n if (dragState.committed && dragState.isInteractiveStart) {\r\n suppressNextClick();\r\n }\r\n resetPendingState();\r\n\r\n if (!isDragging) return;\r\n isDragging = false;\r\n\r\n settleFromRelease();\r\n }\r\n\r\n function handlePointerCancel(e: PointerEvent) {\r\n if (e.pointerType === 'touch' || dragState.activePointerId !== e.pointerId) return;\r\n\r\n try {\r\n sheetRef?.releasePointerCapture(e.pointerId);\r\n } catch {\r\n }\r\n dragState.activePointerId = null;\r\n\r\n // If pending, just reset — nothing visual happened\r\n if (dragState.isPending) {\r\n resetPendingState();\r\n return;\r\n }\r\n resetPendingState();\r\n\r\n if (!isDragging) return;\r\n isDragging = false;\r\n\r\n // No velocity on cancel — settle to the nearest snap point at rest.\r\n const target = sheetHeight - findNearestSnapPoint(currentTranslateY, 0) * effectiveHeight;\r\n animateTo(target, CONFIG.animation.snap, 0);\r\n }\r\n\r\n function handleCloseButtonClick(e: MouseEvent) {\r\n e.stopPropagation();\r\n doClose();\r\n }\r\n\r\n function handleBackdropPointerDown(e: PointerEvent) {\r\n // Record a press that begins ON the backdrop (and only the backdrop). Used to tell\r\n // a deliberate backdrop tap apart from the ghost pointerup described below.\r\n backdropPressPointerId = e.target === backdropRef ? e.pointerId : null;\r\n }\r\n\r\n function handleBackdropClick(e: PointerEvent) {\r\n // Dismiss only when THIS press both started and ended on the backdrop. Gating on the\r\n // press origin (not an isAnimating timer) keeps a deliberate tap instant — even mid\r\n // open-animation — while ignoring the stray release that fires when a press begun\r\n // elsewhere lifts over a just-mounted backdrop (e.g. long-press a button to open the\r\n // sheet, then release: that pointerup lands on the backdrop but never pressed it).\r\n const startedOnBackdrop = backdropPressPointerId === e.pointerId;\r\n backdropPressPointerId = null;\r\n if (!closeOnBackdrop || e.target !== backdropRef || isDragging || !startedOnBackdrop) return;\r\n doClose();\r\n }\r\n\r\n /**\r\n * Escape — CAPTURE phase (see the `onkeydowncapture` binding below), which is load-bearing.\r\n *\r\n * Topmost is resolved across EVERY overlay type, not just among sheets: a Dialog opened\r\n * from inside this sheet should take the keypress alone. But bits-ui listens on `document`\r\n * while this listens on `window`, so on the bubble path bits-ui always runs FIRST — and\r\n * Svelte flushes effects synchronously at the end of its handler, dropping the dialog from\r\n * the stack. This handler would then find ITSELF topmost and close too: one Escape, both\r\n * gone. Capturing at window means we run before anything else can mutate the stack, so the\r\n * check reads the state as it was when the key was pressed.\r\n */\r\n function handleKeydown(e: KeyboardEvent) {\r\n if (e.key === 'Escape' && isVisible && overlayStack.isTopmost(id)) {\r\n e.preventDefault();\r\n doClose();\r\n }\r\n }\r\n\r\n // Svelte action for touch events\r\n function touchEvents(node: HTMLElement) {\r\n node.addEventListener('touchstart', handleTouchStart, {passive: false});\r\n node.addEventListener('touchmove', handleTouchMove, {passive: false});\r\n node.addEventListener('touchend', handleTouchEnd, {passive: true});\r\n\r\n return {\r\n destroy() {\r\n node.removeEventListener('touchstart', handleTouchStart);\r\n node.removeEventListener('touchmove', handleTouchMove);\r\n node.removeEventListener('touchend', handleTouchEnd);\r\n }\r\n };\r\n }\r\n</script>\r\n\r\n<svelte:window onkeydowncapture={handleKeydown}/>\r\n\r\n{#if isVisible}\r\n <!-- Backdrop -->\r\n <!-- svelte-ignore a11y_no_static_element_interactions -->\r\n <div\r\n bind:this={backdropRef}\r\n class={r.backdrop()}\r\n style:z-index={zIndex}\r\n onpointerdown={handleBackdropPointerDown}\r\n onpointerup={handleBackdropClick}\r\n ></div>\r\n\r\n <!-- Sheet -->\r\n <!-- svelte-ignore a11y_no_static_element_interactions -->\r\n <div\r\n bind:this={sheetRef}\r\n class={r.sheet()}\r\n style:z-index={zIndex + 1}\r\n style:height=\"{maxSheetHeight}px\"\r\n data-bottom-sheet-surface\r\n role=\"dialog\"\r\n aria-modal=\"true\"\r\n tabindex={-1}\r\n use:touchEvents\r\n onpointerdown={handlePointerDown}\r\n onpointermove={handlePointerMove}\r\n onpointerup={handlePointerUp}\r\n onpointercancel={handlePointerCancel}\r\n >\r\n <!-- Drag Handle -->\r\n <div class={r.handle()} data-bottom-sheet-handle>\r\n <div class={r.handleBar()}></div>\r\n </div>\r\n\r\n <!-- Header -->\r\n {#if header}\r\n <div class={r.header()} data-bottom-sheet-header>\r\n {@render header()}\r\n {#if showCloseButton}\r\n <button\r\n class={r.closeButton()}\r\n data-bottom-sheet-close\r\n onclick={handleCloseButtonClick}\r\n aria-label=\"Close\"\r\n type=\"button\"\r\n >\r\n <IconX class=\"size-4\"/>\r\n </button>\r\n {/if}\r\n </div>\r\n {/if}\r\n\r\n <!-- Content -->\r\n <div bind:this={contentRef} class={r.content()} data-bottom-sheet-content>\r\n {#if children}\r\n {@render children()}\r\n {/if}\r\n </div>\r\n </div>\r\n{/if}\r\n\r\n<style>\r\n /* Consumer-marked scroll region inside the sheet body. */\r\n [data-bottom-sheet-content] :global([data-bottom-sheet-scroll]) {\r\n flex: 1;\r\n overflow-y: auto;\r\n overflow-x: hidden;\r\n overscroll-behavior: contain;\r\n -webkit-overflow-scrolling: touch;\r\n min-height: 0;\r\n touch-action: pan-y;\r\n }\r\n\r\n /* Suppress native gestures on header children so the drag is captured. */\r\n [data-bottom-sheet-header] :global(*) {\r\n touch-action: none;\r\n user-select: none;\r\n }\r\n\r\n /* Extend the sheet's own surface below its bottom edge. The sheet is only as tall as\r\n its max snap point; if it ever sits a hair above the screen bottom — the open spring's\r\n slight overshoot, or sub-pixel rounding — this keeps the sliver beneath it sheet-colored\r\n instead of letting the background show through. The sheet is overflow-visible so this is\r\n not clipped, and it rides along with the sheet's transform. */\r\n :global([data-bottom-sheet-surface]::after) {\r\n content: '';\r\n position: absolute;\r\n top: 100%;\r\n left: 0;\r\n right: 0;\r\n height: 100%;\r\n background-color: inherit;\r\n pointer-events: none;\r\n }\r\n</style>\r\n"
19
+ },
20
+ {
21
+ "path": "src/lib/components/bottom-sheet/bottomSheetStore.svelte.ts",
22
+ "target": "bottomSheetStore.svelte.ts",
23
+ "type": "ts",
24
+ "content": "/**\n * Bottom Sheet store — Svelte 5 runes.\n *\n * A small singleton that tracks the stack of open bottom sheets so multiple\n * sheets can layer correctly (z-index), report drag progress, and resolve which\n * sheet is topmost (for Escape handling). Sheets register on mount and push/pop\n * themselves from the stack as they open/close. Exposed publicly so apps can\n * drive sheets programmatically (open/close/closeTop) in addition to `bind:open`.\n *\n * ## Now a SHEET-SCOPED VIEW over the shared overlay stack\n *\n * This store used to be the library's only runtime z authority, which is exactly why an\n * overlay opened from inside a sheet (a `z-50` dialog vs. the sheet's 1001) painted behind\n * it. Layering now lives in the shared `overlayStack` (`$lib/hooks`), which every\n * portal-owning overlay registers with; this store keeps sheet-specific concerns (config,\n * drag progress, container refs) and PROJECTS the shared stack down to just the sheets.\n *\n * The public API is unchanged. `stack` / `getStack` / `isTopmost` / `closeTop` /\n * `hasOpenSheets` / `getCombinedBackdropOpacity` still mean \"among bottom sheets\".\n *\n * Two behavior notes:\n * - `getZIndex` now returns a z resolved against ALL overlays, not just sheets. Ordering\n * between sheets is unchanged (still base + 10 per stacked sheet).\n * - `close()` no longer reindexes the survivors, so a sheet's z can stay higher than its\n * position implies after a sheet below it closes. Relative ORDER is still exact; only the\n * absolute number differs. Reindexing live entries collides with close animations, which\n * paint for ~260ms after the entry is gone.\n *\n * For \"topmost across ALL overlay types\" (what Escape should use), read\n * `overlayStack.isTopmost(id)` directly — `BottomSheet.svelte` does.\n */\n\nimport {SvelteMap} from 'svelte/reactivity';\nimport {overlayStack} from '$lib/hooks/overlayStack.svelte.js';\nimport {layer} from '$lib/tokens/index.js';\n\nexport interface BottomSheetConfig {\n id: string;\n snapPoints: number[];\n defaultSnapPoint: number;\n closeOnBackdrop: boolean;\n}\n\nexport interface SheetStackItem {\n id: string;\n zIndex: number;\n progress: number;\n config: BottomSheetConfig;\n}\n\nconst BASE_Z_INDEX = layer.base;\n\nfunction createBottomSheetStore() {\n // SvelteMap, not `$state(new Map())`: `$state` proxies the object but NOT a Map's\n // internals, so `.set()` on a plain $state Map notifies nobody — the `sheetStack`\n // derived below would never see a config or progress change.\n // Registry of all sheet configs\n const registry = new SvelteMap<string, BottomSheetConfig>();\n\n // Drag progress per sheet (0-1). Written ~120×/s during a drag; `sheetStack` is lazy, so\n // this costs one signal write per frame unless something actually reads the stack.\n const progressById = new SvelteMap<string, number>();\n\n // Container refs for scale effect. Plain Map — only ever read imperatively.\n const containerRefs = new Map<string, HTMLElement>();\n\n // The shared stack, projected down to just the sheets — bottom to top, in the shared\n // stack's own order, so a sheet opened above another still reads as being above it.\n const sheetStack = $derived(\n overlayStack.stack\n .filter(item => item.kind === 'bottom-sheet' && registry.has(item.id))\n .map(item => ({\n id: item.id,\n zIndex: item.zIndex,\n progress: progressById.get(item.id) ?? 0,\n config: registry.get(item.id)!\n } satisfies SheetStackItem))\n );\n\n /**\n * Register a sheet with its config\n */\n function register(id: string, config: Omit<BottomSheetConfig, 'id'>): void {\n registry.set(id, {id, ...config});\n }\n\n /**\n * Unregister a sheet\n */\n function unregister(id: string): void {\n registry.delete(id);\n containerRefs.delete(id);\n progressById.delete(id);\n // Remove from stack if present\n overlayStack.remove(id);\n }\n\n /**\n * Open a sheet by ID\n */\n function open(id: string): void {\n if (!registry.has(id)) {\n // Sheet was not registered (component not mounted yet) — ignore.\n return;\n }\n // push() is idempotent an already-open sheet keeps its z rather than jumping.\n overlayStack.push(id, 'bottom-sheet');\n }\n\n /**\n * Close a sheet by ID\n */\n function close(id: string): void {\n overlayStack.remove(id);\n progressById.delete(id);\n }\n\n /**\n * Close the topmost sheet\n */\n function closeTop(): void {\n const top = sheetStack[sheetStack.length - 1];\n if (top) {\n close(top.id);\n }\n }\n\n /**\n * Update progress for a sheet (0-1)\n */\n function updateProgress(id: string, progress: number): void {\n if (!overlayStack.has(id)) return;\n progressById.set(id, Math.max(0, Math.min(1, progress)));\n }\n\n /**\n * Get z-index for a sheet\n */\n function getZIndex(id: string): number {\n return overlayStack.getZIndex(id) ?? BASE_Z_INDEX;\n }\n\n /**\n * Check if a sheet is open\n */\n function isOpen(id: string): boolean {\n return overlayStack.has(id);\n }\n\n /**\n * Check if a sheet is the topmost SHEET. For \"topmost across every overlay type\"\n * (Escape, close-top), use `overlayStack.isTopmost(id)` instead.\n */\n function isTopmost(id: string): boolean {\n return sheetStack[sheetStack.length - 1]?.id === id;\n }\n\n /**\n * Get progress for a sheet\n */\n function getProgress(id: string): number {\n return progressById.get(id) ?? 0;\n }\n\n /**\n * Attach container ref for scale effect\n */\n function attachContainer(sheetId: string, ref: HTMLElement): void {\n containerRefs.set(sheetId, ref);\n }\n\n /**\n * Get container ref for a sheet\n */\n function getContainer(sheetId: string): HTMLElement | undefined {\n return containerRefs.get(sheetId);\n }\n\n /**\n * Get combined backdrop opacity from all sheets\n */\n function getCombinedBackdropOpacity(): number {\n // Use the topmost sheet's progress for backdrop\n const top = sheetStack[sheetStack.length - 1];\n return top ? top.progress * 0.5 : 0;\n }\n\n /**\n * Get the current stack\n */\n function getStack(): SheetStackItem[] {\n return sheetStack;\n }\n\n return {\n get stack() { return sheetStack; },\n get hasOpenSheets() { return sheetStack.length > 0; },\n\n register,\n unregister,\n open,\n close,\n closeTop,\n updateProgress,\n getZIndex,\n isOpen,\n isTopmost,\n getProgress,\n attachContainer,\n getContainer,\n getCombinedBackdropOpacity,\n getStack\n };\n}\n\n// Export singleton instance\nexport const bottomSheetStore = createBottomSheetStore();\n"
25
+ },
26
+ {
27
+ "path": "src/lib/components/bottom-sheet/index.ts",
28
+ "target": "index.ts",
29
+ "type": "ts",
30
+ "content": "export {default as BottomSheet} from './BottomSheet.svelte'\nexport {bottomSheetStore} from './bottomSheetStore.svelte.js'\nexport {bottomSheetRecipe} from './BottomSheet.recipe'\nexport type {BottomSheetRecipeProps, BottomSheetProps} from './BottomSheet.recipe'\nexport type {BottomSheetConfig, SheetStackItem} from './bottomSheetStore.svelte.js'\n"
31
+ }
32
+ ],
33
+ "dependencies": [
34
+ "tailwind-variants"
35
+ ],
36
+ "peerDependencies": [
37
+ "svelte"
38
+ ],
39
+ "registryDependencies": [],
40
+ "sharedModules": {
41
+ "utils": false,
42
+ "icons": true,
43
+ "hooks": true
44
+ },
45
+ "exports": [
46
+ "BottomSheet",
47
+ "bottomSheetStore",
48
+ "bottomSheetRecipe"
49
+ ]
50
+ }
@@ -15,7 +15,7 @@
15
15
  "path": "src/lib/components/combobox/Combobox.svelte",
16
16
  "target": "Combobox.svelte",
17
17
  "type": "svelte",
18
- "content": "<script lang=\"ts\">\n import {Combobox as ComboboxPrimitive} from 'bits-ui';\n import {type ComboboxProps, comboboxRecipe} from './Combobox.recipe';\n import {cn} from '$lib/utils/utils.js';\n import {IconCheck, IconChevronDown} from '$lib/internal/icons';\n import IconX from '@tabler/icons-svelte/icons/x';\n\n let {\n type,\n items = [],\n value = $bindable(undefined),\n placeholder = 'Search…',\n disabled,\n loop,\n allowDeselect,\n onchange,\n class: className,\n ref = $bindable(null),\n name,\n required,\n scrollAlignment,\n freeInput = false,\n checkIcon,\n chevronIcon,\n clearIcon,\n }: ComboboxProps = $props();\n\n let searchText = $state('');\n let open = $state(false);\n\n\n const filteredItems = $derived(\n searchText.trim()\n ? items.filter(i => i.label.toLowerCase().includes(searchText.trim().toLowerCase()))\n : items\n );\n\n // Label of the currently selected item (single mode), or '' if none / custom text\n const selectedLabel = $derived(\n type === 'single' && typeof value === 'string' && value\n ? (items.find(i => i.value === value)?.label ?? '')\n : ''\n );\n\n const selectedChips = $derived(\n type === 'multiple' && Array.isArray(value)\n ? (value as string[]).map(v => ({value: v, label: items.find(i => i.value === v)?.label ?? v}))\n : []\n );\n\n /**\n * inputValue passed to Combobox.Root for single mode:\n * - freeInput mode: always controlled — show selected label when an item\n * matches, otherwise show the raw value (custom free text).\n * - normal mode: undefined → let bits-ui manage it automatically.\n *\n * Setting this to '' on clear ensures the input text is wiped even when\n * the dropdown animation is still playing.\n */\n const controlledInputValue = $derived.by<string | undefined>(() => {\n if (type !== 'single') return undefined;\n if (freeInput) {\n // Show label when value matches an item; otherwise the raw typed value\n return selectedLabel || (typeof value === 'string' ? value : '') || '';\n }\n // Normal mode: after clearing, force the input empty; otherwise let bits-ui handle it\n if (!value) return '';\n return undefined;\n });\n\n // In freeInput mode the X button is visible whenever the input has any text\n const showClearBtn = $derived(\n type === 'single' && (freeInput ? !!(value) : !!(value))\n );\n\n function handleSingleInput(e: Event & {currentTarget: HTMLInputElement}) {\n searchText = e.currentTarget.value;\n if (freeInput) {\n // Typed text IS the value in free-input mode\n value = searchText as typeof value;\n onchange?.(searchText);\n // Close immediately (same tick) when no suggestions match —\n // avoids a \"no results\" flash before a reactive effect would fire.\n if (searchText.trim() && !items.some(i => i.label.toLowerCase().includes(searchText.trim().toLowerCase()))) {\n open = false;\n }\n }\n }\n\n function clearOne(v: string) {\n const next = (value as string[]).filter(x => x !== v);\n value = next as typeof value;\n onchange?.(next);\n }\n\n function clearAll() {\n value = type === 'multiple' ? ([] as typeof value) : (undefined as typeof value);\n onchange?.(type === 'multiple' ? [] : '');\n searchText = '';\n }\n\n const r = $derived(comboboxRecipe({open, disabled}));\n</script>\n\n{#if type === 'single'}\n <ComboboxPrimitive.Root\n type=\"single\"\n bind:value={value as string | undefined}\n bind:open\n onValueChange={(v) => onchange?.(v)}\n {disabled}\n {loop}\n {allowDeselect}\n {name}\n {required}\n {scrollAlignment}\n {items}\n inputValue={controlledInputValue}\n onOpenChange={(o) => { if (!o) searchText = ''; }}\n >\n <div bind:this={ref} class={r.root({class: cn('h-[var(--tera-control-height-md)] px-[var(--tera-control-px-md)]', className)})}>\n <ComboboxPrimitive.Input\n oninput={handleSingleInput}\n placeholder={(!freeInput && selectedLabel) ? selectedLabel : placeholder}\n clearOnDeselect\n class={r.input()}\n />\n {#if showClearBtn}\n <button\n type=\"button\"\n onpointerdown={(e) => e.stopPropagation()}\n onclick={(e) => { e.stopPropagation(); clearAll(); }}\n class={r.clear()}\n aria-label=\"Clear selection\"\n >\n {#if clearIcon}\n {@render clearIcon()}\n {:else}\n <IconX class=\"size-3.5\" />\n {/if}\n </button>\n {/if}\n <ComboboxPrimitive.Trigger class={r.trigger({class: 'shrink-0'})}>\n {#if chevronIcon}\n {@render chevronIcon()}\n {:else}\n <IconChevronDown class={cn(r.chevron(), open && 'rotate-180')} />\n {/if}\n </ComboboxPrimitive.Trigger>\n </div>\n\n {#if !freeInput || filteredItems.length > 0}\n <ComboboxPrimitive.Portal>\n <ComboboxPrimitive.Content\n sideOffset={4}\n customAnchor={ref}\n class={r.content({class: 'min-w-[var(--bits-combobox-anchor-width)]'})}\n >\n <ComboboxPrimitive.Viewport class={r.viewport()}>\n {#each filteredItems as item (item.value)}\n <ComboboxPrimitive.Item\n value={item.value}\n label={item.label}\n disabled={item.disabled}\n class={r.item()}\n >\n {#snippet children({selected})}\n <span class=\"flex-1\">{item.label}</span>\n {#if selected}\n {#if checkIcon}\n {@render checkIcon()}\n {:else}\n <IconCheck class=\"size-4 text-interactive shrink-0\" />\n {/if}\n {/if}\n {/snippet}\n </ComboboxPrimitive.Item>\n {:else}\n <div class={r.empty()}>No results found.</div>\n {/each}\n </ComboboxPrimitive.Viewport>\n </ComboboxPrimitive.Content>\n </ComboboxPrimitive.Portal>\n {/if}\n </ComboboxPrimitive.Root>\n\n{:else}\n <ComboboxPrimitive.Root\n type=\"multiple\"\n bind:value={value as string[] | undefined}\n bind:open\n onValueChange={(v) => onchange?.(v)}\n {disabled}\n {loop}\n {name}\n {required}\n {scrollAlignment}\n onOpenChange={(o) => { if (!o) searchText = ''; }}\n >\n <div bind:this={ref} class={r.root({class: cn('min-h-[var(--tera-control-height-md)] h-auto flex-wrap gap-1 px-[var(--tera-control-px-md)] py-1.5', className)})}>\n {#each selectedChips as chip (chip.value)}\n <span class={r.chip()}>\n {chip.label}\n <button\n type=\"button\"\n onpointerdown={(e) => e.stopPropagation()}\n onclick={(e) => { e.stopPropagation(); clearOne(chip.value); }}\n class={r.chipRemove()}\n aria-label=\"Remove {chip.label}\"\n >\n {#if clearIcon}\n {@render clearIcon()}\n {:else}\n <IconX class=\"size-3\" />\n {/if}\n </button>\n </span>\n {/each}\n <ComboboxPrimitive.Input\n oninput={(e) => (searchText = e.currentTarget.value)}\n placeholder={selectedChips.length === 0 ? placeholder : ''}\n clearOnDeselect\n class={r.input({class: 'min-w-[80px]'})}\n />\n <span class=\"ml-auto flex items-center gap-1 shrink-0 self-center\">\n {#if selectedChips.length > 0}\n <button\n type=\"button\"\n onpointerdown={(e) => e.stopPropagation()}\n onclick={(e) => { e.stopPropagation(); clearAll(); }}\n class={r.clear()}\n aria-label=\"Clear all\"\n >\n {#if clearIcon}\n {@render clearIcon()}\n {:else}\n <IconX class=\"size-3.5\" />\n {/if}\n </button>\n {/if}\n <ComboboxPrimitive.Trigger class={r.trigger()}>\n {#if chevronIcon}\n {@render chevronIcon()}\n {:else}\n <IconChevronDown class={cn(r.chevron(), open && 'rotate-180')} />\n {/if}\n </ComboboxPrimitive.Trigger>\n </span>\n </div>\n\n <ComboboxPrimitive.Portal>\n <ComboboxPrimitive.Content\n sideOffset={4}\n customAnchor={ref}\n class={r.content({class: 'min-w-[var(--bits-combobox-anchor-width)]'})}\n >\n <ComboboxPrimitive.Viewport class={r.viewport()}>\n {#each filteredItems as item (item.value)}\n <ComboboxPrimitive.Item\n value={item.value}\n label={item.label}\n disabled={item.disabled}\n class={r.item()}\n >\n {#snippet children({selected})}\n <span class=\"flex-1\">{item.label}</span>\n {#if selected}\n {#if checkIcon}\n {@render checkIcon()}\n {:else}\n <IconCheck class=\"size-4 text-interactive shrink-0\" />\n {/if}\n {/if}\n {/snippet}\n </ComboboxPrimitive.Item>\n {:else}\n <div class={r.empty()}>No results found.</div>\n {/each}\n </ComboboxPrimitive.Viewport>\n </ComboboxPrimitive.Content>\n </ComboboxPrimitive.Portal>\n </ComboboxPrimitive.Root>\n{/if}\n"
18
+ "content": "<script lang=\"ts\">\n import {Combobox as ComboboxPrimitive} from 'bits-ui';\n import {type ComboboxProps, comboboxRecipe} from './Combobox.recipe';\n import {cn} from '$lib/utils/utils.js';\n import {IconCheck, IconChevronDown} from '$lib/internal/icons';\n import IconX from '@tabler/icons-svelte/icons/x';\n import {overlayLayer, withZIndex} from '$lib/hooks';\n\n let {\n type,\n items = [],\n value = $bindable(undefined),\n placeholder = 'Search…',\n disabled,\n loop,\n allowDeselect,\n onchange,\n class: className,\n ref = $bindable(null),\n name,\n required,\n scrollAlignment,\n freeInput = false,\n checkIcon,\n chevronIcon,\n clearIcon,\n }: ComboboxProps = $props();\n\n let searchText = $state('');\n let open = $state(false);\n\n // Shared stacking authority — a combobox opened inside a Dialog/BottomSheet must land\n // above it, which its hardcoded z-50 could not do.\n const layer = overlayLayer({isOpen: () => open, kind: 'menu'});\n const contentZIndex = $derived(layer.zIndex === undefined ? undefined : layer.zIndex + 1);\n\n\n const filteredItems = $derived(\n searchText.trim()\n ? items.filter(i => i.label.toLowerCase().includes(searchText.trim().toLowerCase()))\n : items\n );\n\n // Label of the currently selected item (single mode), or '' if none / custom text\n const selectedLabel = $derived(\n type === 'single' && typeof value === 'string' && value\n ? (items.find(i => i.value === value)?.label ?? '')\n : ''\n );\n\n const selectedChips = $derived(\n type === 'multiple' && Array.isArray(value)\n ? (value as string[]).map(v => ({value: v, label: items.find(i => i.value === v)?.label ?? v}))\n : []\n );\n\n /**\n * inputValue passed to Combobox.Root for single mode:\n * - freeInput mode: always controlled — show selected label when an item\n * matches, otherwise show the raw value (custom free text).\n * - normal mode: undefined → let bits-ui manage it automatically.\n *\n * Setting this to '' on clear ensures the input text is wiped even when\n * the dropdown animation is still playing.\n */\n const controlledInputValue = $derived.by<string | undefined>(() => {\n if (type !== 'single') return undefined;\n if (freeInput) {\n // Show label when value matches an item; otherwise the raw typed value\n return selectedLabel || (typeof value === 'string' ? value : '') || '';\n }\n // Normal mode: after clearing, force the input empty; otherwise let bits-ui handle it\n if (!value) return '';\n return undefined;\n });\n\n // In freeInput mode the X button is visible whenever the input has any text\n const showClearBtn = $derived(\n type === 'single' && (freeInput ? !!(value) : !!(value))\n );\n\n function handleSingleInput(e: Event & {currentTarget: HTMLInputElement}) {\n searchText = e.currentTarget.value;\n if (freeInput) {\n // Typed text IS the value in free-input mode\n value = searchText as typeof value;\n onchange?.(searchText);\n // Close immediately (same tick) when no suggestions match —\n // avoids a \"no results\" flash before a reactive effect would fire.\n if (searchText.trim() && !items.some(i => i.label.toLowerCase().includes(searchText.trim().toLowerCase()))) {\n open = false;\n }\n }\n }\n\n function clearOne(v: string) {\n const next = (value as string[]).filter(x => x !== v);\n value = next as typeof value;\n onchange?.(next);\n }\n\n function clearAll() {\n value = type === 'multiple' ? ([] as typeof value) : (undefined as typeof value);\n onchange?.(type === 'multiple' ? [] : '');\n searchText = '';\n }\n\n const r = $derived(comboboxRecipe({open, disabled}));\n</script>\n\n{#if type === 'single'}\n <ComboboxPrimitive.Root\n type=\"single\"\n bind:value={value as string | undefined}\n bind:open\n onValueChange={(v) => onchange?.(v)}\n {disabled}\n {loop}\n {allowDeselect}\n {name}\n {required}\n {scrollAlignment}\n {items}\n inputValue={controlledInputValue}\n onOpenChange={(o) => { if (!o) searchText = ''; }}\n >\n <div bind:this={ref} class={r.root({class: cn('h-[var(--tera-control-height-md)] px-[var(--tera-control-px-md)]', className)})}>\n <ComboboxPrimitive.Input\n oninput={handleSingleInput}\n placeholder={(!freeInput && selectedLabel) ? selectedLabel : placeholder}\n clearOnDeselect\n class={r.input()}\n />\n {#if showClearBtn}\n <button\n type=\"button\"\n onpointerdown={(e) => e.stopPropagation()}\n onclick={(e) => { e.stopPropagation(); clearAll(); }}\n class={r.clear()}\n aria-label=\"Clear selection\"\n >\n {#if clearIcon}\n {@render clearIcon()}\n {:else}\n <IconX class=\"size-3.5\" />\n {/if}\n </button>\n {/if}\n <ComboboxPrimitive.Trigger class={r.trigger({class: 'shrink-0'})}>\n {#if chevronIcon}\n {@render chevronIcon()}\n {:else}\n <IconChevronDown class={cn(r.chevron(), open && 'rotate-180')} />\n {/if}\n </ComboboxPrimitive.Trigger>\n </div>\n\n {#if !freeInput || filteredItems.length > 0}\n <ComboboxPrimitive.Portal>\n <ComboboxPrimitive.Content\n sideOffset={4}\n customAnchor={ref}\n class={r.content({class: 'min-w-[var(--bits-combobox-anchor-width)]'})}\n style={withZIndex(undefined, contentZIndex)}\n >\n <ComboboxPrimitive.Viewport class={r.viewport()}>\n {#each filteredItems as item (item.value)}\n <ComboboxPrimitive.Item\n value={item.value}\n label={item.label}\n disabled={item.disabled}\n class={r.item()}\n >\n {#snippet children({selected})}\n <span class=\"flex-1\">{item.label}</span>\n {#if selected}\n {#if checkIcon}\n {@render checkIcon()}\n {:else}\n <IconCheck class=\"size-4 text-interactive shrink-0\" />\n {/if}\n {/if}\n {/snippet}\n </ComboboxPrimitive.Item>\n {:else}\n <div class={r.empty()}>No results found.</div>\n {/each}\n </ComboboxPrimitive.Viewport>\n </ComboboxPrimitive.Content>\n </ComboboxPrimitive.Portal>\n {/if}\n </ComboboxPrimitive.Root>\n\n{:else}\n <ComboboxPrimitive.Root\n type=\"multiple\"\n bind:value={value as string[] | undefined}\n bind:open\n onValueChange={(v) => onchange?.(v)}\n {disabled}\n {loop}\n {name}\n {required}\n {scrollAlignment}\n onOpenChange={(o) => { if (!o) searchText = ''; }}\n >\n <div bind:this={ref} class={r.root({class: cn('min-h-[var(--tera-control-height-md)] h-auto flex-wrap gap-1 px-[var(--tera-control-px-md)] py-1.5', className)})}>\n {#each selectedChips as chip (chip.value)}\n <span class={r.chip()}>\n {chip.label}\n <button\n type=\"button\"\n onpointerdown={(e) => e.stopPropagation()}\n onclick={(e) => { e.stopPropagation(); clearOne(chip.value); }}\n class={r.chipRemove()}\n aria-label=\"Remove {chip.label}\"\n >\n {#if clearIcon}\n {@render clearIcon()}\n {:else}\n <IconX class=\"size-3\" />\n {/if}\n </button>\n </span>\n {/each}\n <ComboboxPrimitive.Input\n oninput={(e) => (searchText = e.currentTarget.value)}\n placeholder={selectedChips.length === 0 ? placeholder : ''}\n clearOnDeselect\n class={r.input({class: 'min-w-[80px]'})}\n />\n <span class=\"ml-auto flex items-center gap-1 shrink-0 self-center\">\n {#if selectedChips.length > 0}\n <button\n type=\"button\"\n onpointerdown={(e) => e.stopPropagation()}\n onclick={(e) => { e.stopPropagation(); clearAll(); }}\n class={r.clear()}\n aria-label=\"Clear all\"\n >\n {#if clearIcon}\n {@render clearIcon()}\n {:else}\n <IconX class=\"size-3.5\" />\n {/if}\n </button>\n {/if}\n <ComboboxPrimitive.Trigger class={r.trigger()}>\n {#if chevronIcon}\n {@render chevronIcon()}\n {:else}\n <IconChevronDown class={cn(r.chevron(), open && 'rotate-180')} />\n {/if}\n </ComboboxPrimitive.Trigger>\n </span>\n </div>\n\n <ComboboxPrimitive.Portal>\n <ComboboxPrimitive.Content\n sideOffset={4}\n customAnchor={ref}\n class={r.content({class: 'min-w-[var(--bits-combobox-anchor-width)]'})}\n >\n <ComboboxPrimitive.Viewport class={r.viewport()}>\n {#each filteredItems as item (item.value)}\n <ComboboxPrimitive.Item\n value={item.value}\n label={item.label}\n disabled={item.disabled}\n class={r.item()}\n >\n {#snippet children({selected})}\n <span class=\"flex-1\">{item.label}</span>\n {#if selected}\n {#if checkIcon}\n {@render checkIcon()}\n {:else}\n <IconCheck class=\"size-4 text-interactive shrink-0\" />\n {/if}\n {/if}\n {/snippet}\n </ComboboxPrimitive.Item>\n {:else}\n <div class={r.empty()}>No results found.</div>\n {/each}\n </ComboboxPrimitive.Viewport>\n </ComboboxPrimitive.Content>\n </ComboboxPrimitive.Portal>\n </ComboboxPrimitive.Root>\n{/if}\n"
19
19
  },
20
20
  {
21
21
  "path": "src/lib/components/combobox/index.ts",
@@ -36,7 +36,7 @@
36
36
  "sharedModules": {
37
37
  "utils": true,
38
38
  "icons": true,
39
- "hooks": false
39
+ "hooks": true
40
40
  },
41
41
  "exports": [
42
42
  "Combobox",