@trackunit/react-components 1.21.14 → 1.21.15

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.
package/index.cjs.js CHANGED
@@ -7546,6 +7546,2044 @@ const SectionHeader = ({ title, subtitle, "data-testid": dataTestId, addons, ref
7546
7546
  return (jsxRuntime.jsxs("div", { className: "flex flex-col", ref: ref, children: [jsxRuntime.jsx(reactHelmetAsync.HelmetProvider, { children: jsxRuntime.jsx(reactHelmetAsync.Helmet, { title: title }) }), jsxRuntime.jsxs("div", { className: "mb-2 flex flex-row gap-2", children: [jsxRuntime.jsxs("div", { className: "flex grow flex-col gap-2", children: [jsxRuntime.jsx(Heading, { "data-testid": dataTestId, variant: "secondary", children: title }), subtitle ? (jsxRuntime.jsx(Heading, { subtle: true, variant: "subtitle", children: subtitle })) : null] }), addons !== null && addons !== undefined ? jsxRuntime.jsx("div", { className: "flex gap-2", children: addons }) : null] }), jsxRuntime.jsx(Spacer, { size: "small" })] }));
7547
7547
  };
7548
7548
 
7549
+ /**
7550
+ * Differentiate between the first and subsequent renders.
7551
+ *
7552
+ * @returns {boolean} Returns true if it is the first render, false otherwise.
7553
+ */
7554
+ const useIsFirstRender = () => {
7555
+ const [isFirstRender, setIsFirstRender] = react.useState(true);
7556
+ react.useLayoutEffect(() => {
7557
+ queueMicrotask(() => {
7558
+ setIsFirstRender(false);
7559
+ });
7560
+ }, []);
7561
+ return isFirstRender;
7562
+ };
7563
+
7564
+ /**
7565
+ * Width constraint for anchored sheets (start/end positioning).
7566
+ * Uses container-relative units with a pixel cap.
7567
+ */
7568
+ /**
7569
+ * Transition timing for snap animations — gentle deceleration with minimal overshoot.
7570
+ */
7571
+ const SHEET_TRANSITION_DURATION_MS = 300;
7572
+ const SHEET_TRANSITION_DURATION = `${SHEET_TRANSITION_DURATION_MS}ms`;
7573
+ const SHEET_TRANSITION_EASING = "cubic-bezier(0.2, 0.0, 0.0, 1.0)";
7574
+ /**
7575
+ * Threshold (as a fraction of container height) below the smallest snap
7576
+ * at which the sheet will close on release.
7577
+ */
7578
+ const CLOSE_THRESHOLD_FRACTION = 0.15;
7579
+ /**
7580
+ * Dead zone in pixels below the smallest snap before the dismiss
7581
+ * visual indicator starts showing. Prevents the cross from appearing
7582
+ * too early when the user barely drags past the lowest snap.
7583
+ */
7584
+ const DISMISS_DEAD_ZONE_PX = 20;
7585
+ /**
7586
+ * Pixel range within which the snap-proximity visual feedback activates.
7587
+ * The handle widens and darkens as the sheet approaches a snap point.
7588
+ */
7589
+ const SNAP_PROXIMITY_RANGE_PX = 50;
7590
+ /** Fallback pixel height for the docked state. */
7591
+ const DOCKED_HEIGHT_PX = 64;
7592
+ /**
7593
+ * Top margin subtracted from the largest snap level so the sheet never
7594
+ * fully covers the container (leaves a small gap at the top).
7595
+ */
7596
+ const FULL_HEIGHT_TOP_MARGIN_PX = 40;
7597
+ /** Minimum pixel spacing between adjacent snap points (and between dock and first level). */
7598
+ const MIN_SNAP_SPACING_PX = 120;
7599
+ /** Maximum number of snap levels regardless of available height. */
7600
+ const MAX_SNAP_LEVELS = 5;
7601
+ /** Minimum number of snap levels when there is enough space. */
7602
+ const MIN_SNAP_LEVELS = 2;
7603
+ /**
7604
+ * Determines the number of snap levels based on container height.
7605
+ *
7606
+ * When docking is enabled the dock height is treated as an anchor
7607
+ * and only the space above it is divided into adaptive levels, ensuring
7608
+ * the minimum spacing guarantee applies between dock and first snap too.
7609
+ */
7610
+ const getAdaptiveLevelCount = (containerHeight, dockingEnabled, dockedHeightPx = DOCKED_HEIGHT_PX) => {
7611
+ const maxHeight = Math.max(0, containerHeight - FULL_HEIGHT_TOP_MARGIN_PX);
7612
+ if (maxHeight <= 0)
7613
+ return 0;
7614
+ const availableSpace = dockingEnabled ? maxHeight - dockedHeightPx : maxHeight;
7615
+ if (availableSpace <= 0)
7616
+ return 0;
7617
+ const bySpacing = Math.floor(availableSpace / MIN_SNAP_SPACING_PX);
7618
+ return Math.max(MIN_SNAP_LEVELS, Math.min(MAX_SNAP_LEVELS, bySpacing));
7619
+ };
7620
+ /**
7621
+ * Computes an array of snap level heights in pixels, evenly distributed
7622
+ * across the available space. When docking is enabled the distribution
7623
+ * starts from the docked height rather than 0, so the first level is
7624
+ * always at least `MIN_SNAP_SPACING_PX` above the dock.
7625
+ *
7626
+ * The resulting heights are sorted ascending (smallest first).
7627
+ *
7628
+ * @param containerHeight - Container height in pixels.
7629
+ * @param dockingEnabled - Whether docked mode is available.
7630
+ * @param dockedHeightPx - Pixel height of the docked state (measured from content).
7631
+ * @returns {ReadonlyArray<number>} Array of pixel heights for each snap level.
7632
+ */
7633
+ const computeSnapLevelHeights = (containerHeight, dockingEnabled, dockedHeightPx = DOCKED_HEIGHT_PX) => {
7634
+ const levelCount = getAdaptiveLevelCount(containerHeight, dockingEnabled, dockedHeightPx);
7635
+ const maxHeight = Math.max(0, containerHeight - FULL_HEIGHT_TOP_MARGIN_PX);
7636
+ const base = dockingEnabled ? dockedHeightPx : 0;
7637
+ const range = maxHeight - base;
7638
+ if (levelCount <= 0 || range <= 0) {
7639
+ return maxHeight > 0 ? [maxHeight] : [];
7640
+ }
7641
+ const levels = [];
7642
+ for (let i = 0; i < levelCount; i++) {
7643
+ const fraction = (i + 1) / levelCount;
7644
+ levels.push(Math.round(base + fraction * range));
7645
+ }
7646
+ return levels;
7647
+ };
7648
+ /**
7649
+ * Converts a snap level index to a CSS height string using container-relative units.
7650
+ *
7651
+ * When docking is enabled the height range starts from the docked height
7652
+ * so the CSS value reflects the shifted distribution.
7653
+ *
7654
+ * @param levelIndex - Zero-based snap level index.
7655
+ * @param totalLevels - Total number of snap levels.
7656
+ * @param dockingEnabled - Whether docked mode is available.
7657
+ * @param dockedHeightPx - Pixel height of the docked state (measured from content).
7658
+ */
7659
+ const getSnapLevelCssHeight = (levelIndex, totalLevels, dockingEnabled, dockedHeightPx = DOCKED_HEIGHT_PX) => {
7660
+ if (totalLevels <= 0)
7661
+ return "0px";
7662
+ const fraction = (levelIndex + 1) / totalLevels;
7663
+ if (fraction >= 1) {
7664
+ return `calc(100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px)`;
7665
+ }
7666
+ if (dockingEnabled) {
7667
+ return `calc(${dockedHeightPx}px + ${fraction} * (100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX + dockedHeightPx}px))`;
7668
+ }
7669
+ return `calc(${fraction} * (100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px))`;
7670
+ };
7671
+
7672
+ /**
7673
+ * Container wrapper that creates the positioning context and container query
7674
+ * context for the Sheet. Sets `container-type: size` so cqh and cqw units
7675
+ * resolve against this wrapper's dimensions.
7676
+ *
7677
+ * Always absolutely positioned within the provided container element.
7678
+ */
7679
+ const cvaSheetContainer = cssClassVarianceUtilities.cvaMerge(["absolute", "inset-0", "z-overlay", "overflow-clip", "[container-type:size]"], {
7680
+ variants: {
7681
+ docked: {
7682
+ true: ["pointer-events-none"],
7683
+ false: [],
7684
+ },
7685
+ },
7686
+ defaultVariants: {
7687
+ docked: false,
7688
+ },
7689
+ });
7690
+ /**
7691
+ * Sheet panel — the main content surface, always positioned at the bottom
7692
+ * of the container.
7693
+ *
7694
+ * Height and vertical positioning are controlled via inline styles returned
7695
+ * by `getSheetPanelStyle()`:
7696
+ *
7697
+ * - `height`: Target height for the current snap (e.g., "50cqh"), or
7698
+ * `--sheet-drag-height` during drag so the sheet stays anchored at the bottom
7699
+ * - `transform`: translateY for entry slide-up animation
7700
+ * - `transition`: separate curves for entry (transform), close (height collapse),
7701
+ * and snap changes (height)
7702
+ *
7703
+ * During drag, the gesture hook sets `--sheet-drag-height` and `transition: none`
7704
+ * directly on the element for immediate visual feedback, then restores on release.
7705
+ */
7706
+ const cvaSheetPanel = cssClassVarianceUtilities.cvaMerge(["absolute", "bottom-0", "flex", "flex-col", "pointer-events-auto", "rounded-b-none", "rounded-t-xl", "z-overlay"], {
7707
+ variants: {
7708
+ anchor: {
7709
+ center: ["inset-x-0", "mx-auto", "max-w-3xl"],
7710
+ start: ["left-[12px]", "max-w-[min(90cqw,420px)]"],
7711
+ end: ["right-[12px]", "max-w-[min(90cqw,420px)]"],
7712
+ },
7713
+ variant: {
7714
+ default: [],
7715
+ modal: [],
7716
+ floating: ["rounded-b-xl", "bottom-[12px]"],
7717
+ },
7718
+ },
7719
+ defaultVariants: {
7720
+ anchor: "center",
7721
+ variant: "default",
7722
+ },
7723
+ });
7724
+ /**
7725
+ * Scrollable area below the drag handle. Nested inside the Sheet panel (Card)
7726
+ * so the Card's base `overflow-clip` clips this element's scrollbar at the
7727
+ * rounded corners. The handle stays fixed above, outside the scroll context.
7728
+ *
7729
+ * `fillHeight` controls whether the scroll area stretches to fill available
7730
+ * panel space. Enabled for level/docked modes (fixed panel height); disabled
7731
+ * for fit mode so that `scrollHeight` reflects natural content height rather
7732
+ * than the stretched layout — critical for correct fit-height measurement.
7733
+ */
7734
+ const cvaSheetScrollArea = cssClassVarianceUtilities.cvaMerge(["flex", "flex-col", "min-h-0", "overflow-x-hidden", "overflow-y-auto"], {
7735
+ variants: {
7736
+ fillHeight: {
7737
+ true: ["flex-grow"],
7738
+ false: [],
7739
+ },
7740
+ },
7741
+ defaultVariants: {
7742
+ fillHeight: true,
7743
+ },
7744
+ });
7745
+ /**
7746
+ * CSS transition shorthand for the sheet panel during normal (open) state.
7747
+ * Animates both `transform` (entry slide-up) and `height` (snap changes).
7748
+ */
7749
+ const SHEET_OPEN_TRANSITION = `transform ${SHEET_TRANSITION_DURATION} ${SHEET_TRANSITION_EASING}, height ${SHEET_TRANSITION_DURATION} ${SHEET_TRANSITION_EASING}`;
7750
+ /**
7751
+ * Transform-only transition for auto-height mode during entry. Height is
7752
+ * excluded because modern browsers (Chrome 129+) can transition `fit-content`
7753
+ * via `interpolate-size: allow-keywords`, which causes unwanted height
7754
+ * animation as content mounts during the slide-up.
7755
+ */
7756
+ const SHEET_TRANSFORM_TRANSITION = `transform ${SHEET_TRANSITION_DURATION} ${SHEET_TRANSITION_EASING}`;
7757
+ /**
7758
+ * Height-only transition for the collapse phase of the close animation.
7759
+ */
7760
+ const SHEET_CLOSE_TRANSITION = `height ${SHEET_TRANSITION_DURATION} ${SHEET_TRANSITION_EASING}`;
7761
+ // ---------------------------------------------------------------------------
7762
+ // Close animation strategies (per variant)
7763
+ // ---------------------------------------------------------------------------
7764
+ /** Height the floating variant collapses to before the vanish phase. */
7765
+ const FLOATING_COLLAPSE_HEIGHT_PX = 20;
7766
+ /** Snappy transition for the floating vanish phase (scale + fade). */
7767
+ const FLOATING_VANISH_TRANSITION = `opacity 120ms ease-out, transform 120ms ease-out`;
7768
+ /**
7769
+ * default / modal — single-phase collapse to zero height.
7770
+ * Unmounts when height reaches 0.
7771
+ */
7772
+ const getDefaultCloseStyle = () => ({
7773
+ height: "0",
7774
+ overflow: "hidden",
7775
+ transition: SHEET_CLOSE_TRANSITION,
7776
+ });
7777
+ /**
7778
+ * floating — two-phase close:
7779
+ * 1. "collapsing" shrinks height to a small pill.
7780
+ * 2. "vanishing" scales down and fades out the remaining pill.
7781
+ */
7782
+ const getFloatingCloseStyle = (phase) => {
7783
+ switch (phase) {
7784
+ case "collapsing":
7785
+ return {
7786
+ height: `${FLOATING_COLLAPSE_HEIGHT_PX}px`,
7787
+ overflow: "hidden",
7788
+ transition: SHEET_CLOSE_TRANSITION,
7789
+ };
7790
+ case "vanishing":
7791
+ return {
7792
+ height: `${FLOATING_COLLAPSE_HEIGHT_PX}px`,
7793
+ opacity: 0,
7794
+ overflow: "hidden",
7795
+ transform: "scale(0.8)",
7796
+ transition: FLOATING_VANISH_TRANSITION,
7797
+ };
7798
+ default:
7799
+ return {};
7800
+ }
7801
+ };
7802
+ /**
7803
+ * Resolves the close-animation CSS for the given variant and phase.
7804
+ */
7805
+ const getCloseStyle = (variant, phase) => {
7806
+ switch (variant) {
7807
+ case "floating":
7808
+ return getFloatingCloseStyle(phase);
7809
+ case "default":
7810
+ case "modal":
7811
+ return getDefaultCloseStyle();
7812
+ default:
7813
+ return getDefaultCloseStyle();
7814
+ }
7815
+ };
7816
+ // ---------------------------------------------------------------------------
7817
+ /**
7818
+ * Returns inline styles for the sheet panel element.
7819
+ *
7820
+ * Entry animation uses a transform slide-up (translateY 100% → 0).
7821
+ * Close animation is variant-aware — see `getCloseStyle` for details.
7822
+ * Snap-level changes animate height while the sheet is open.
7823
+ */
7824
+ const getSheetPanelStyle = ({ snapHeight, isOpen, closePhase, variant, autoHeight, maxHeight, isDragging, suppressTransition = false, }) => {
7825
+ if (closePhase !== "idle") {
7826
+ return getCloseStyle(variant, closePhase);
7827
+ }
7828
+ return {
7829
+ height: autoHeight ? "fit-content" : `var(--sheet-drag-height, ${snapHeight})`,
7830
+ maxHeight: maxHeight ?? `calc(100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px)`,
7831
+ pointerEvents: "auto",
7832
+ transform: isOpen ? "translateY(0)" : "translateY(100%)",
7833
+ transition: suppressTransition
7834
+ ? "none"
7835
+ : isDragging
7836
+ ? "none"
7837
+ : autoHeight || !isOpen
7838
+ ? SHEET_TRANSFORM_TRANSITION
7839
+ : SHEET_OPEN_TRANSITION,
7840
+ };
7841
+ };
7842
+
7843
+ /**
7844
+ * Centralized visual intensity parameters for the sheet handle line.
7845
+ *
7846
+ * All values control the handle bar (line) color, which mixes between
7847
+ * `base` and `emphasized` colors. The mixing factor is the maximum of:
7848
+ * - `--sheet-dismiss-progress` (0-1, set on the panel by gesture hook or Sheet effect)
7849
+ * - `--sheet-snap-proximity` * proximityWeight (approaching a snap point)
7850
+ * - `--handle-interaction` (hover / dragging / pressed states below)
7851
+ *
7852
+ * Interaction values are set via CSS custom property on the handle div
7853
+ * (Tailwind arbitrary properties + pseudo-classes). Keep these in sync
7854
+ * with the CVA class strings in `cvaSheetHandle`.
7855
+ */
7856
+ const HANDLE_VISUALS = {
7857
+ line: {
7858
+ base: "var(--color-neutral-300)",
7859
+ emphasized: "var(--color-neutral-600)",
7860
+ proximityWeight: 0.5,
7861
+ },
7862
+ };
7863
+ const cvaSheetHandle = cssClassVarianceUtilities.cvaMerge([
7864
+ "flex",
7865
+ "items-center",
7866
+ "justify-center",
7867
+ "w-full",
7868
+ "py-3",
7869
+ "touch-action-none",
7870
+ "select-none",
7871
+ "shrink-0",
7872
+ "bg-white",
7873
+ ], {
7874
+ variants: {
7875
+ isDragging: {
7876
+ true: ["cursor-grabbing", "[--handle-interaction:0.25]"],
7877
+ false: [
7878
+ "cursor-grab",
7879
+ "[--handle-interaction:0]",
7880
+ "hover:[--handle-interaction:0.15]",
7881
+ "active:[--handle-interaction:0.5]",
7882
+ ],
7883
+ },
7884
+ },
7885
+ defaultVariants: {
7886
+ isDragging: false,
7887
+ },
7888
+ });
7889
+ const cvaSheetHandleLine = cssClassVarianceUtilities.cvaMerge(["w-8", "h-1", "rounded-full", "origin-center"], {
7890
+ variants: {
7891
+ isDragging: {
7892
+ true: [],
7893
+ false: ["[transition:width_150ms_ease,background-color_150ms_ease]"],
7894
+ },
7895
+ },
7896
+ defaultVariants: {
7897
+ isDragging: false,
7898
+ },
7899
+ });
7900
+ const handleBackgroundColor = `color-mix(in srgb, ${HANDLE_VISUALS.line.base} calc(100% - 100% * max(var(--sheet-dismiss-progress, 0), var(--sheet-snap-proximity, 0) * ${HANDLE_VISUALS.line.proximityWeight}, var(--handle-interaction, 0))), ${HANDLE_VISUALS.line.emphasized} calc(100% * max(var(--sheet-dismiss-progress, 0), var(--sheet-snap-proximity, 0) * ${HANDLE_VISUALS.line.proximityWeight}, var(--handle-interaction, 0))))`;
7901
+ /**
7902
+ * Visual drag indicator at the top of the Sheet.
7903
+ *
7904
+ * Renders a single handle line that narrows into a short stub as the user
7905
+ * drags toward the dismiss threshold, with color fading from neutral to dark.
7906
+ * The dismiss visual is driven entirely by the `--sheet-dismiss-progress`
7907
+ * CSS custom property set on the parent panel element — this component
7908
+ * has no dismiss-related state or logic.
7909
+ *
7910
+ * Accepts pointer event handlers from useSheetGestures and forwards them
7911
+ * to the handle element. The element has `touch-action: none` so pointer
7912
+ * events work reliably on touch devices.
7913
+ */
7914
+ const SheetHandle = ({ onPointerDown, onPointerMove, onPointerUp, onLostPointerCapture, onClick, isDragging = false, onMouseEnter, onMouseLeave, "data-testid": dataTestId, }) => {
7915
+ // Reaches 1 before dismiss-progress does (×1.3) so the visual
7916
+ // completes early, giving a snappy feel before the sheet actually closes.
7917
+ const dismissIntensity = "min(1, var(--sheet-dismiss-progress, 0) * 1.3)";
7918
+ // Width multiplier components (base 2rem = 32px, height is h-1 = 4px):
7919
+ // 1 → resting width: 32px
7920
+ // + stretch * 0.5 → widens up to 48px when over-pulling upward
7921
+ // - dismiss * 0.75 → shrinks to 0.25× = 8px at full dismiss (2× height, short stub)
7922
+ // + snap * 0.15 → subtle widen when approaching a snap point
7923
+ const handleWidth = `calc(2rem * (1 + var(--sheet-stretch-progress, 0) * 0.5 - ${dismissIntensity} * 0.75 + var(--sheet-snap-proximity, 0) * 0.15))`;
7924
+ return (jsxRuntime.jsx("div", { "aria-label": "Sheet drag handle", className: cvaSheetHandle({ isDragging }), "data-sheet-handle": true, "data-testid": dataTestId, onClick: onClick, onLostPointerCapture: onLostPointerCapture, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, role: "separator", children: jsxRuntime.jsx("div", { className: cvaSheetHandleLine({ isDragging }), style: {
7925
+ backgroundColor: handleBackgroundColor,
7926
+ width: handleWidth,
7927
+ } }) }));
7928
+ };
7929
+
7930
+ const cvaSheetOverlay = cssClassVarianceUtilities.cvaMerge(["absolute", "inset-0", "transition-opacity", "duration-300", "[transition-timing-function:cubic-bezier(0.2,0,0,1)]"], {
7931
+ variants: {
7932
+ visible: {
7933
+ true: ["bg-black/50", "pointer-events-auto"],
7934
+ false: ["bg-transparent", "pointer-events-none"],
7935
+ },
7936
+ },
7937
+ defaultVariants: {
7938
+ visible: false,
7939
+ },
7940
+ });
7941
+ /**
7942
+ * Semi-transparent backdrop overlay for the Sheet.
7943
+ *
7944
+ * Fades in/out using CSS transitions. When not visible, the overlay is
7945
+ * transparent with pointer-events disabled so the background remains
7946
+ * interactable. Dismiss is handled by Floating UI's `useDismiss`
7947
+ * outside-press detection — clicks on the visible overlay bubble to the
7948
+ * document where `useDismiss` triggers the close flow.
7949
+ */
7950
+ const SheetOverlay = ({ visible, "data-testid": dataTestId }) => (jsxRuntime.jsx("div", { "aria-hidden": "true", className: cvaSheetOverlay({ visible }), "data-testid": dataTestId }));
7951
+
7952
+ const INITIAL_ANIMATION_STATE = {
7953
+ shouldRender: false,
7954
+ visuallyOpen: false,
7955
+ closePhase: "idle",
7956
+ prevIsOpen: false,
7957
+ };
7958
+ /** Reducer managing the Sheet component's mount/unmount animation lifecycle. */
7959
+ const sheetAnimationReducer = (state, action) => {
7960
+ switch (action.type) {
7961
+ case "SYNC_OPEN": {
7962
+ if (action.isOpen === state.prevIsOpen)
7963
+ return state;
7964
+ if (action.isOpen) {
7965
+ return {
7966
+ ...state,
7967
+ prevIsOpen: true,
7968
+ shouldRender: true,
7969
+ visuallyOpen: action.skipEntryAnimation,
7970
+ closePhase: "idle",
7971
+ };
7972
+ }
7973
+ return {
7974
+ ...state,
7975
+ prevIsOpen: false,
7976
+ visuallyOpen: false,
7977
+ closePhase: "collapsing",
7978
+ };
7979
+ }
7980
+ case "SET_VISUALLY_OPEN":
7981
+ return state.visuallyOpen ? state : { ...state, visuallyOpen: true };
7982
+ case "UNMOUNT":
7983
+ return state.shouldRender ? { ...state, shouldRender: false, closePhase: "idle" } : state;
7984
+ case "ENSURE_RENDER":
7985
+ return state.shouldRender ? state : { ...state, shouldRender: true };
7986
+ case "START_VANISH":
7987
+ return state.closePhase === "collapsing" ? { ...state, closePhase: "vanishing" } : state;
7988
+ default:
7989
+ return state;
7990
+ }
7991
+ };
7992
+
7993
+ /**
7994
+ * Manages the dismiss-intent visual on the sheet handle.
7995
+ *
7996
+ * Sets `--sheet-dismiss-progress: 1` on the panel element when the user
7997
+ * signals dismiss intent — either by holding Cmd/Ctrl+Shift while hovering
7998
+ * the handle, or while the close animation is in progress.
7999
+ *
8000
+ * This is the single owner of `--sheet-dismiss-progress` outside of the
8001
+ * gesture hook's drag flow. The gesture hook manages the property during
8002
+ * active drag (continuous 0-1 values); this hook manages it for the
8003
+ * binary on/off dismiss visual (modifier+hover, close animation).
8004
+ */
8005
+ const useSheetDismissIntent = ({ closable, closePhase, panelRef, }) => {
8006
+ const [closeModifierHeld, setCloseModifierHeld] = react.useState(false);
8007
+ const { hovering, onMouseEnter, onMouseLeave } = useHover({ debounced: false });
8008
+ react.useEffect(() => {
8009
+ if (!closable)
8010
+ return;
8011
+ const update = (e) => {
8012
+ setCloseModifierHeld((e.metaKey || e.ctrlKey) && e.shiftKey);
8013
+ };
8014
+ const reset = () => setCloseModifierHeld(false);
8015
+ window.addEventListener("keydown", update);
8016
+ window.addEventListener("keyup", update);
8017
+ window.addEventListener("blur", reset);
8018
+ return () => {
8019
+ window.removeEventListener("keydown", update);
8020
+ window.removeEventListener("keyup", update);
8021
+ window.removeEventListener("blur", reset);
8022
+ };
8023
+ }, [closable]);
8024
+ const showDismiss = closePhase !== "idle" || (closeModifierHeld && hovering && closable);
8025
+ react.useEffect(() => {
8026
+ const panel = panelRef.current;
8027
+ if (!panel)
8028
+ return;
8029
+ if (showDismiss) {
8030
+ panel.style.setProperty("--sheet-dismiss-progress", "1");
8031
+ }
8032
+ else {
8033
+ panel.style.removeProperty("--sheet-dismiss-progress");
8034
+ }
8035
+ }, [showDismiss, panelRef]);
8036
+ return react.useMemo(() => ({ onMouseEnter, onMouseLeave }), [onMouseEnter, onMouseLeave]);
8037
+ };
8038
+
8039
+ /** Minimum velocity (px/ms) to trigger directional snap selection. */
8040
+ const VELOCITY_THRESHOLD = 0.5;
8041
+ /** Dampening factor for rubber-band effect past snap bounds. */
8042
+ const RUBBER_BAND_FACTOR = 0.3;
8043
+ /**
8044
+ * Calculates vertical velocity from pointer position history.
8045
+ * Positive = downward, negative = upward.
8046
+ */
8047
+ const calculateVelocity = (samples) => {
8048
+ if (samples.length < 2)
8049
+ return 0;
8050
+ const first = samples[0];
8051
+ const last = samples[samples.length - 1];
8052
+ if (!first || !last)
8053
+ return 0;
8054
+ const dt = last.timestamp - first.timestamp;
8055
+ if (dt === 0)
8056
+ return 0;
8057
+ return (last.y - first.y) / dt;
8058
+ };
8059
+ /**
8060
+ * Applies rubber-band dampening when the effective height exceeds snap bounds.
8061
+ *
8062
+ * Within [minSnap, maxSnap] the raw drag delta passes through unchanged.
8063
+ * Beyond maxSnap the excess movement is dampened by RUBBER_BAND_FACTOR.
8064
+ *
8065
+ * Below minSnap behaviour depends on `closable`:
8066
+ * - **closable = true** — the sheet follows the finger freely (clamped at 0)
8067
+ * so the user can always reach the close threshold.
8068
+ * - **closable = false** — rubber-band dampening is applied (same feel as
8069
+ * the upward over-stretch) because the sheet cannot be dismissed.
8070
+ *
8071
+ * Returns the constrained effective height in pixels.
8072
+ */
8073
+ const computeConstrainedEffectiveHeight = (rawDelta, activeSnapHeight, minSnap, maxSnap, closable) => {
8074
+ const effectiveHeight = activeSnapHeight - rawDelta;
8075
+ if (effectiveHeight > maxSnap) {
8076
+ const excess = effectiveHeight - maxSnap;
8077
+ return maxSnap + excess * RUBBER_BAND_FACTOR;
8078
+ }
8079
+ if (effectiveHeight < minSnap) {
8080
+ if (closable) {
8081
+ return Math.max(0, effectiveHeight);
8082
+ }
8083
+ const deficit = minSnap - effectiveHeight;
8084
+ return minSnap - deficit * RUBBER_BAND_FACTOR;
8085
+ }
8086
+ return effectiveHeight;
8087
+ };
8088
+ /**
8089
+ * Computes how far the sheet has progressed toward dismissal (0 = at min snap, 1 = at close threshold).
8090
+ *
8091
+ * A dead zone below the min snap absorbs small overdrags before the dismiss
8092
+ * indicator starts. Returns 0 when close is not available.
8093
+ */
8094
+ const computeDismissProgress = (rawEffectiveHeight, minSnap, containerHeight, closeThresholdFraction, dismissDeadZonePx) => {
8095
+ const closeThreshold = minSnap - containerHeight * closeThresholdFraction;
8096
+ const adjustedMinSnap = minSnap - dismissDeadZonePx;
8097
+ const range = adjustedMinSnap - closeThreshold;
8098
+ if (range <= 0)
8099
+ return 0;
8100
+ return Math.max(0, Math.min(1, (adjustedMinSnap - rawEffectiveHeight) / range));
8101
+ };
8102
+ /**
8103
+ * Computes a 0-1 proximity value indicating how close the drag position is
8104
+ * to the nearest snap point in the current drag direction.
8105
+ *
8106
+ * 1 = directly at a snap point, 0 = further than `proximityRange` away.
8107
+ */
8108
+ const computeSnapProximity = (constrainedHeight, snapHeights, direction, proximityRange) => {
8109
+ let nearestApproachingDist = Infinity;
8110
+ for (const h of snapHeights) {
8111
+ const snapDir = h - constrainedHeight;
8112
+ if (direction * snapDir > 0) {
8113
+ const d = Math.abs(constrainedHeight - h);
8114
+ if (d < nearestApproachingDist)
8115
+ nearestApproachingDist = d;
8116
+ }
8117
+ }
8118
+ return nearestApproachingDist < Infinity ? Math.max(0, 1 - nearestApproachingDist / proximityRange) : 0;
8119
+ };
8120
+ /**
8121
+ * Resolves the target snap height based on effective position and velocity.
8122
+ *
8123
+ * - High upward velocity -> next larger snap above the current effective height
8124
+ * - High downward velocity -> next smaller snap below the current effective height
8125
+ * - Low velocity -> snap to the nearest point
8126
+ *
8127
+ * snapHeights must be sorted ascending.
8128
+ */
8129
+ const resolveSnapTarget = (effectiveHeight, snapHeights, velocity) => {
8130
+ if (snapHeights.length === 0)
8131
+ return 0;
8132
+ if (snapHeights.length === 1)
8133
+ return snapHeights[0] ?? 0;
8134
+ if (velocity < -VELOCITY_THRESHOLD) {
8135
+ for (const height of snapHeights) {
8136
+ if (height > effectiveHeight)
8137
+ return height;
8138
+ }
8139
+ return snapHeights[snapHeights.length - 1] ?? 0;
8140
+ }
8141
+ if (velocity > VELOCITY_THRESHOLD) {
8142
+ for (let i = snapHeights.length - 1; i >= 0; i--) {
8143
+ const height = snapHeights[i];
8144
+ if (height !== undefined && height < effectiveHeight)
8145
+ return height;
8146
+ }
8147
+ return snapHeights[0] ?? 0;
8148
+ }
8149
+ let nearest = snapHeights[0] ?? 0;
8150
+ let minDistance = Math.abs(effectiveHeight - nearest);
8151
+ for (const height of snapHeights) {
8152
+ const distance = Math.abs(effectiveHeight - height);
8153
+ if (distance < minDistance) {
8154
+ minDistance = distance;
8155
+ nearest = height;
8156
+ }
8157
+ }
8158
+ return nearest;
8159
+ };
8160
+
8161
+ /** Duration (ms) of the fade-out after the sheet arrives at a snap point. */
8162
+ const PROXIMITY_FADE_OUT_MS = 150;
8163
+ /**
8164
+ * Manages the `--sheet-snap-proximity` CSS custom property animation
8165
+ * during post-release snap transitions.
8166
+ *
8167
+ * Tracks the sheet height via `getBoundingClientRect` each frame,
8168
+ * computes proximity to the target snap, and fades the value out once
8169
+ * the sheet has arrived.
8170
+ */
8171
+ const useProximityAnimation = (sheetRef) => {
8172
+ const proximityRafRef = react.useRef(0);
8173
+ const cancelProximityAnimation = react.useCallback(() => {
8174
+ if (proximityRafRef.current !== 0) {
8175
+ cancelAnimationFrame(proximityRafRef.current);
8176
+ proximityRafRef.current = 0;
8177
+ }
8178
+ const sheet = sheetRef.current;
8179
+ if (sheet) {
8180
+ sheet.style.removeProperty("--sheet-snap-proximity");
8181
+ }
8182
+ }, [sheetRef]);
8183
+ const startProximityAnimation = react.useCallback((sheet, targetHeight) => {
8184
+ let fadeOutStart = null;
8185
+ const tick = (timestamp) => {
8186
+ const currentHeight = sheet.getBoundingClientRect().height;
8187
+ const distance = Math.abs(currentHeight - targetHeight);
8188
+ const proximity = Math.max(0, 1 - distance / SNAP_PROXIMITY_RANGE_PX);
8189
+ if (proximity >= 0.95 && fadeOutStart === null) {
8190
+ fadeOutStart = timestamp;
8191
+ }
8192
+ if (fadeOutStart !== null) {
8193
+ const elapsed = timestamp - fadeOutStart;
8194
+ const fadeProgress = Math.min(1, elapsed / PROXIMITY_FADE_OUT_MS);
8195
+ const fadedProximity = proximity * (1 - fadeProgress);
8196
+ if (fadeProgress >= 1) {
8197
+ sheet.style.removeProperty("--sheet-snap-proximity");
8198
+ proximityRafRef.current = 0;
8199
+ return;
8200
+ }
8201
+ sheet.style.setProperty("--sheet-snap-proximity", String(fadedProximity));
8202
+ }
8203
+ else {
8204
+ sheet.style.setProperty("--sheet-snap-proximity", String(proximity));
8205
+ }
8206
+ proximityRafRef.current = requestAnimationFrame(tick);
8207
+ };
8208
+ proximityRafRef.current = requestAnimationFrame(tick);
8209
+ }, []);
8210
+ react.useEffect(() => cancelProximityAnimation, [cancelProximityAnimation]);
8211
+ return react.useMemo(() => ({ cancelProximityAnimation, startProximityAnimation }), [cancelProximityAnimation, startProximityAnimation]);
8212
+ };
8213
+
8214
+ /** Number of pointer samples retained for velocity calculation. */
8215
+ const MAX_POINTER_SAMPLES = 5;
8216
+ /**
8217
+ * Maximum absolute pointer displacement (in px) for a gesture to qualify
8218
+ * as a tap/click rather than a drag. Keeps click detection robust on
8219
+ * touch screens where fingers wobble slightly.
8220
+ */
8221
+ const CLICK_MOVEMENT_THRESHOLD_PX = 5;
8222
+ /**
8223
+ * Minimum displacement (px) from the last direction-change position before
8224
+ * the committed drag direction flips. Prevents sub-pixel pointer jitter
8225
+ * from causing rapid direction toggles and visual flickering.
8226
+ */
8227
+ const DIRECTION_HYSTERESIS_PX = 2;
8228
+ /**
8229
+ * Prevents the spurious native click event that fires after a drag gesture.
8230
+ *
8231
+ * Pointer capture retargets mouseup to the same element as mousedown, so the
8232
+ * browser synthesizes a click even when the pointer traveled far. A one-time
8233
+ * capturing listener stops propagation before React's root delegation sees it.
8234
+ */
8235
+ const suppressNextClick = (target) => {
8236
+ target.addEventListener("click", ev => {
8237
+ ev.stopPropagation();
8238
+ }, { once: true, capture: true });
8239
+ };
8240
+ /**
8241
+ * Hook providing pointer-event-based drag gesture handling for a bottom sheet.
8242
+ *
8243
+ * Uses native Pointer Events with pointer capture for reliable cross-device
8244
+ * tracking. During drag, the hook directly manipulates the sheet element's
8245
+ * `--sheet-drag-height` CSS custom property and disables transitions for
8246
+ * immediate visual feedback. On release, it restores CSS transitions and
8247
+ * calls `onSnap` or `onClose` based on position and velocity.
8248
+ *
8249
+ * The consuming Sheet component should use `height: var(--sheet-drag-height, <snapHeight>)`
8250
+ * so the sheet stays anchored at the bottom while its height changes during drag.
8251
+ */
8252
+ const useSheetGestures = (props) => {
8253
+ const { snapHeights, activeSnapHeight, sheetRef, onSnap, onClose, containerHeight, enabled } = props;
8254
+ const [isDragging, setIsDragging] = react.useState(false);
8255
+ const isDraggingRef = react.useRef(false);
8256
+ const dragStartYRef = react.useRef(0);
8257
+ const maxDisplacementRef = react.useRef(0);
8258
+ const directionAnchorRef = react.useRef(0);
8259
+ const dragDirectionRef = react.useRef(0);
8260
+ const pointerSamplesRef = react.useRef([]);
8261
+ const onSnapRef = react.useRef(onSnap);
8262
+ const onCloseRef = react.useRef(onClose);
8263
+ const snapHeightsRef = react.useRef(snapHeights);
8264
+ const activeSnapHeightRef = react.useRef(activeSnapHeight);
8265
+ const containerHeightRef = react.useRef(containerHeight);
8266
+ react.useLayoutEffect(() => {
8267
+ onSnapRef.current = onSnap;
8268
+ onCloseRef.current = onClose;
8269
+ snapHeightsRef.current = snapHeights;
8270
+ if (!isDraggingRef.current) {
8271
+ activeSnapHeightRef.current = activeSnapHeight;
8272
+ }
8273
+ containerHeightRef.current = containerHeight;
8274
+ });
8275
+ const { cancelProximityAnimation, startProximityAnimation } = useProximityAnimation(sheetRef);
8276
+ const resetDragState = react.useCallback(() => {
8277
+ if (!isDraggingRef.current)
8278
+ return;
8279
+ isDraggingRef.current = false;
8280
+ setIsDragging(false);
8281
+ pointerSamplesRef.current = [];
8282
+ const sheet = sheetRef.current;
8283
+ if (sheet) {
8284
+ sheet.style.removeProperty("transition");
8285
+ sheet.style.removeProperty("--sheet-drag-height");
8286
+ sheet.style.removeProperty("--sheet-stretch-progress");
8287
+ sheet.style.removeProperty("--sheet-snap-proximity");
8288
+ sheet.style.removeProperty("--sheet-dismiss-progress");
8289
+ }
8290
+ }, [sheetRef]);
8291
+ const onLostPointerCapture = react.useCallback(() => {
8292
+ resetDragState();
8293
+ }, [resetDragState]);
8294
+ const onPointerDown = react.useCallback((e) => {
8295
+ if (!enabled || e.button !== 0 || snapHeightsRef.current.length === 0) {
8296
+ return;
8297
+ }
8298
+ e.currentTarget.setPointerCapture(e.pointerId);
8299
+ cancelProximityAnimation();
8300
+ dragStartYRef.current = e.clientY;
8301
+ maxDisplacementRef.current = 0;
8302
+ directionAnchorRef.current = activeSnapHeightRef.current;
8303
+ dragDirectionRef.current = 0;
8304
+ pointerSamplesRef.current = [{ y: e.clientY, timestamp: e.timeStamp }];
8305
+ isDraggingRef.current = true;
8306
+ setIsDragging(true);
8307
+ const sheet = sheetRef.current;
8308
+ if (sheet) {
8309
+ sheet.style.transition = "none";
8310
+ }
8311
+ }, [enabled, sheetRef, cancelProximityAnimation]);
8312
+ const onPointerMove = react.useCallback((e) => {
8313
+ if (!isDraggingRef.current)
8314
+ return;
8315
+ const rawDelta = e.clientY - dragStartYRef.current;
8316
+ maxDisplacementRef.current = Math.max(maxDisplacementRef.current, Math.abs(rawDelta));
8317
+ const samples = pointerSamplesRef.current;
8318
+ samples.push({ y: e.clientY, timestamp: e.timeStamp });
8319
+ if (samples.length > MAX_POINTER_SAMPLES) {
8320
+ samples.shift();
8321
+ }
8322
+ const snaps = snapHeightsRef.current;
8323
+ const minSnap = snaps[0] ?? 0;
8324
+ const maxSnap = snaps[snaps.length - 1] ?? 0;
8325
+ const closable = onCloseRef.current !== undefined;
8326
+ const constrainedHeight = computeConstrainedEffectiveHeight(rawDelta, activeSnapHeightRef.current, minSnap, maxSnap, closable);
8327
+ const sheet = sheetRef.current;
8328
+ if (sheet) {
8329
+ sheet.style.setProperty("--sheet-drag-height", `${constrainedHeight}px`);
8330
+ const rawEffective = activeSnapHeightRef.current - rawDelta;
8331
+ if (rawEffective > maxSnap) {
8332
+ const stretchAmount = rawEffective - maxSnap;
8333
+ const stretchProgress = Math.min(1, stretchAmount / 80);
8334
+ sheet.style.setProperty("--sheet-stretch-progress", String(stretchProgress));
8335
+ sheet.style.removeProperty("--sheet-dismiss-progress");
8336
+ sheet.style.removeProperty("--sheet-snap-proximity");
8337
+ }
8338
+ else if (rawEffective < minSnap && !closable) {
8339
+ const stretchAmount = minSnap - rawEffective;
8340
+ const stretchProgress = Math.min(1, stretchAmount / 80);
8341
+ sheet.style.setProperty("--sheet-stretch-progress", String(stretchProgress));
8342
+ sheet.style.removeProperty("--sheet-dismiss-progress");
8343
+ sheet.style.removeProperty("--sheet-snap-proximity");
8344
+ }
8345
+ else {
8346
+ sheet.style.removeProperty("--sheet-stretch-progress");
8347
+ let dismissProgress = 0;
8348
+ if (closable) {
8349
+ dismissProgress = computeDismissProgress(rawEffective, minSnap, containerHeightRef.current, CLOSE_THRESHOLD_FRACTION, DISMISS_DEAD_ZONE_PX);
8350
+ sheet.style.setProperty("--sheet-dismiss-progress", String(dismissProgress));
8351
+ }
8352
+ const displacement = constrainedHeight - directionAnchorRef.current;
8353
+ if (displacement > DIRECTION_HYSTERESIS_PX && dragDirectionRef.current !== 1) {
8354
+ dragDirectionRef.current = 1;
8355
+ directionAnchorRef.current = constrainedHeight;
8356
+ }
8357
+ else if (displacement < -DIRECTION_HYSTERESIS_PX && dragDirectionRef.current !== -1) {
8358
+ dragDirectionRef.current = -1;
8359
+ directionAnchorRef.current = constrainedHeight;
8360
+ }
8361
+ if (dismissProgress > 0) {
8362
+ sheet.style.removeProperty("--sheet-snap-proximity");
8363
+ }
8364
+ else {
8365
+ const snapProximity = computeSnapProximity(constrainedHeight, snaps, dragDirectionRef.current, SNAP_PROXIMITY_RANGE_PX);
8366
+ sheet.style.setProperty("--sheet-snap-proximity", String(snapProximity));
8367
+ }
8368
+ }
8369
+ }
8370
+ }, [sheetRef]);
8371
+ const onPointerUp = react.useCallback((e) => {
8372
+ if (!isDraggingRef.current)
8373
+ return;
8374
+ isDraggingRef.current = false;
8375
+ setIsDragging(false);
8376
+ const sheet = sheetRef.current;
8377
+ const wasClick = maxDisplacementRef.current < CLICK_MOVEMENT_THRESHOLD_PX;
8378
+ if (wasClick) {
8379
+ pointerSamplesRef.current = [];
8380
+ if (sheet) {
8381
+ sheet.style.removeProperty("transition");
8382
+ sheet.style.removeProperty("--sheet-drag-height");
8383
+ sheet.style.removeProperty("--sheet-stretch-progress");
8384
+ sheet.style.removeProperty("--sheet-snap-proximity");
8385
+ }
8386
+ return;
8387
+ }
8388
+ suppressNextClick(e.currentTarget);
8389
+ const rawDelta = e.clientY - dragStartYRef.current;
8390
+ const velocity = calculateVelocity(pointerSamplesRef.current);
8391
+ pointerSamplesRef.current = [];
8392
+ const effectiveHeight = activeSnapHeightRef.current - rawDelta;
8393
+ const snaps = snapHeightsRef.current;
8394
+ const minSnap = snaps[0] ?? 0;
8395
+ const closeThreshold = minSnap - containerHeightRef.current * CLOSE_THRESHOLD_FRACTION;
8396
+ if (effectiveHeight < closeThreshold || (velocity > VELOCITY_THRESHOLD && effectiveHeight < minSnap)) {
8397
+ if (sheet) {
8398
+ sheet.style.removeProperty("--sheet-drag-height");
8399
+ sheet.style.removeProperty("--sheet-stretch-progress");
8400
+ sheet.style.removeProperty("--sheet-snap-proximity");
8401
+ sheet.style.setProperty("--sheet-dismiss-progress", "1");
8402
+ }
8403
+ const closeHandler = onCloseRef.current;
8404
+ if (closeHandler) {
8405
+ closeHandler();
8406
+ return;
8407
+ }
8408
+ onSnapRef.current(minSnap);
8409
+ return;
8410
+ }
8411
+ const targetHeight = resolveSnapTarget(effectiveHeight, snaps, velocity);
8412
+ if (sheet) {
8413
+ sheet.style.removeProperty("--sheet-drag-height");
8414
+ sheet.style.removeProperty("--sheet-dismiss-progress");
8415
+ sheet.style.removeProperty("--sheet-stretch-progress");
8416
+ startProximityAnimation(sheet, targetHeight);
8417
+ }
8418
+ onSnapRef.current(targetHeight);
8419
+ }, [sheetRef, startProximityAnimation]);
8420
+ return react.useMemo(() => ({
8421
+ onPointerDown,
8422
+ onPointerMove,
8423
+ onPointerUp,
8424
+ onLostPointerCapture,
8425
+ isDragging,
8426
+ }), [onPointerDown, onPointerMove, onPointerUp, onLostPointerCapture, isDragging]);
8427
+ };
8428
+
8429
+ /**
8430
+ * Manages the Sheet's mount/unmount animation lifecycle.
8431
+ *
8432
+ * - Ensures `shouldRender` is set when re-opening after a stale transitionend closure.
8433
+ * - Triggers the open animation by forcing the browser to acknowledge the
8434
+ * off-screen `translateY(100%)` position (via `getComputedStyle`) before
8435
+ * dispatching `SET_VISUALLY_OPEN`, which changes the inline transform to
8436
+ * `translateY(0)`. The existing CSS transition on `transform` then animates
8437
+ * the slide-up smoothly.
8438
+ * - Handles `transitionend` on the panel to unmount after the close transition.
8439
+ */
8440
+ const useSheetLifecycle = ({ isOpen, entryAnimation, visuallyOpen, variant, container, panelRef, panelEl, dispatch, onCloseComplete, fitHeightReady, }) => {
8441
+ useWatch({
8442
+ value: isOpen,
8443
+ onChange: value => {
8444
+ if (value === true) {
8445
+ dispatch({ type: "ENSURE_RENDER" });
8446
+ }
8447
+ },
8448
+ immediate: true,
8449
+ });
8450
+ react.useLayoutEffect(() => {
8451
+ if (!isOpen || !entryAnimation || visuallyOpen || !container || !fitHeightReady)
8452
+ return;
8453
+ const panel = panelRef.current;
8454
+ if (!panel)
8455
+ return;
8456
+ void getComputedStyle(panel).transform;
8457
+ dispatch({ type: "SET_VISUALLY_OPEN" });
8458
+ }, [isOpen, entryAnimation, visuallyOpen, container, fitHeightReady, dispatch, panelRef, panelEl]);
8459
+ const isOpenRef = react.useRef(isOpen);
8460
+ react.useEffect(() => {
8461
+ isOpenRef.current = isOpen;
8462
+ }, [isOpen]);
8463
+ const variantRef = react.useRef(variant);
8464
+ react.useEffect(() => {
8465
+ variantRef.current = variant;
8466
+ }, [variant]);
8467
+ const onCloseCompleteRef = react.useRef(onCloseComplete);
8468
+ react.useEffect(() => {
8469
+ onCloseCompleteRef.current = onCloseComplete;
8470
+ }, [onCloseComplete]);
8471
+ const handleTransitionEnd = react.useCallback((e) => {
8472
+ if (e.target !== e.currentTarget || isOpenRef.current)
8473
+ return;
8474
+ switch (e.propertyName) {
8475
+ case "height":
8476
+ if (variantRef.current === "floating") {
8477
+ dispatch({ type: "START_VANISH" });
8478
+ }
8479
+ else {
8480
+ dispatch({ type: "UNMOUNT" });
8481
+ onCloseCompleteRef.current?.();
8482
+ }
8483
+ break;
8484
+ case "opacity":
8485
+ dispatch({ type: "UNMOUNT" });
8486
+ onCloseCompleteRef.current?.();
8487
+ break;
8488
+ }
8489
+ }, [dispatch]);
8490
+ return react.useMemo(() => ({ handleTransitionEnd }), [handleTransitionEnd]);
8491
+ };
8492
+
8493
+ const EMPTY_SNAP_HEIGHTS = [];
8494
+ const sortAscending = (a, b) => a - b;
8495
+ /**
8496
+ * Computes the active snap height in pixels for the current display state.
8497
+ *
8498
+ * Docked → measured docked height.
8499
+ * Fit mode with a measured height → use that height.
8500
+ * Otherwise → look up the level height from the precomputed array.
8501
+ */
8502
+ const computeActiveSnapHeight = (sizingMode, fitHeight, levelHeights, displayLevel, dockedHeightPx = DOCKED_HEIGHT_PX) => {
8503
+ if (sizingMode === "docked")
8504
+ return dockedHeightPx;
8505
+ if (sizingMode === "fit" && fitHeight > 0)
8506
+ return fitHeight;
8507
+ return levelHeights[displayLevel] ?? 0;
8508
+ };
8509
+ /**
8510
+ * Builds the set of pixel heights the gesture system snaps to.
8511
+ *
8512
+ * - Snapping disabled → empty array.
8513
+ * - Fit mode with measured height → `[dockedHeight?, fitHeight]`.
8514
+ * - Fit mode before measurement → falls back to level-based snaps.
8515
+ * - Normal mode → level heights with optional docked height prepended.
8516
+ *
8517
+ * The returned array is always sorted ascending.
8518
+ */
8519
+ const computeGestureSnapHeights = (effectiveSnapping, sizingMode, fitHeight, levelHeights, dockingEnabled, dockedHeightPx = DOCKED_HEIGHT_PX) => {
8520
+ if (!effectiveSnapping)
8521
+ return EMPTY_SNAP_HEIGHTS;
8522
+ if (sizingMode === "fit") {
8523
+ if (fitHeight <= 0) {
8524
+ const fallbackHeights = [...levelHeights];
8525
+ if (dockingEnabled) {
8526
+ fallbackHeights.unshift(dockedHeightPx);
8527
+ }
8528
+ return fallbackHeights.length > 0 ? fallbackHeights.toSorted(sortAscending) : EMPTY_SNAP_HEIGHTS;
8529
+ }
8530
+ const fitHeights = [fitHeight, ...levelHeights];
8531
+ if (dockingEnabled) {
8532
+ fitHeights.push(dockedHeightPx);
8533
+ }
8534
+ return fitHeights.toSorted(sortAscending);
8535
+ }
8536
+ const heights = [...levelHeights];
8537
+ if (dockingEnabled) {
8538
+ heights.unshift(dockedHeightPx);
8539
+ }
8540
+ return heights.toSorted(sortAscending);
8541
+ };
8542
+
8543
+ /**
8544
+ * Temporarily overrides an element's styles to measure its natural content
8545
+ * height, then restores the originals. Extracted from the hook body so the
8546
+ * lint rule for hook-parameter immutability does not flag the mutations.
8547
+ */
8548
+ const measureFallback = (el) => {
8549
+ const prevHeight = el.style.height;
8550
+ const prevOverflow = el.style.overflow;
8551
+ const prevTransition = el.style.transition;
8552
+ el.style.height = "auto";
8553
+ el.style.overflow = "visible";
8554
+ el.style.transition = "none";
8555
+ const height = el.scrollHeight;
8556
+ const fallbackStyle = getComputedStyle(el);
8557
+ const totalBorder = (parseFloat(fallbackStyle.borderTopWidth) || 0) + (parseFloat(fallbackStyle.borderBottomWidth) || 0);
8558
+ el.style.height = prevHeight;
8559
+ el.style.overflow = prevOverflow;
8560
+ el.style.transition = prevTransition;
8561
+ return height + totalBorder;
8562
+ };
8563
+ /**
8564
+ * Clones the element, measures its natural content height via scrollHeight,
8565
+ * and commits the result to the provided setter. Falls back to a temporary
8566
+ * inline-style override if the clone reports zero (e.g. in jsdom).
8567
+ */
8568
+ const measureAndCommit = (element, commit) => {
8569
+ const clone = element.cloneNode(true);
8570
+ if (!(clone instanceof HTMLDivElement))
8571
+ return;
8572
+ clone.style.cssText = `
8573
+ position: absolute;
8574
+ visibility: hidden;
8575
+ height: auto;
8576
+ overflow: visible;
8577
+ pointer-events: none;
8578
+ `;
8579
+ const container = element.parentElement ?? document.body;
8580
+ container.appendChild(clone);
8581
+ const cloneScrollH = clone.scrollHeight;
8582
+ let measured;
8583
+ if (cloneScrollH > 0) {
8584
+ const cloneStyle = getComputedStyle(clone);
8585
+ measured =
8586
+ cloneScrollH + (parseFloat(cloneStyle.borderTopWidth) || 0) + (parseFloat(cloneStyle.borderBottomWidth) || 0);
8587
+ }
8588
+ else {
8589
+ measured = measureFallback(element);
8590
+ }
8591
+ clone.remove();
8592
+ commit(measured);
8593
+ };
8594
+ /**
8595
+ * Measures the panel's natural content height when docked and persists it for
8596
+ * the next dock transition.
8597
+ *
8598
+ * The stored "last measured" height is used as the animation target when
8599
+ * transitioning from expanded to docked, so the sheet animates smoothly to
8600
+ * the correct height. If no value has been stored yet (first dock), the
8601
+ * consumer falls back to DOCKED_HEIGHT_PX — no animation is acceptable.
8602
+ *
8603
+ * When transitioning from expanded to docked, the panel retains its previous
8604
+ * height during layout, so scrollHeight returns the expanded value. We measure
8605
+ * via a hidden clone (height: auto) so we never mutate the panel — the height
8606
+ * transition runs without disruption.
8607
+ *
8608
+ * The value is intentionally NOT reset when leaving docked mode, so re-entry
8609
+ * always has the correct target for the animation.
8610
+ *
8611
+ * Does NOT use a ResizeObserver because the gesture system manipulates the
8612
+ * element's CSS height during drag, which inflates `scrollHeight` beyond the
8613
+ * true content size and would create a feedback loop.
8614
+ *
8615
+ * Returns 0 before the first measurement. The consumer falls back to
8616
+ * DOCKED_HEIGHT_PX until a positive value is available.
8617
+ */
8618
+ const useDockedContentHeight = (element, shouldRender, sizingMode) => {
8619
+ const [lastMeasuredHeight, setLastMeasuredHeight] = react.useState(0);
8620
+ react.useLayoutEffect(() => {
8621
+ if (!shouldRender || sizingMode !== "docked" || !element)
8622
+ return undefined;
8623
+ measureAndCommit(element, setLastMeasuredHeight);
8624
+ return undefined;
8625
+ }, [shouldRender, sizingMode, element]);
8626
+ return lastMeasuredHeight;
8627
+ };
8628
+
8629
+ /**
8630
+ * Measures the panel's natural content height for fit mode via ResizeObserver.
8631
+ *
8632
+ * Only active when `sizingMode` is `"fit"` and the panel is visible (docked
8633
+ * height is measured separately by useDockedContentHeight).
8634
+ *
8635
+ * Measures the sum of children's `scrollHeight` rather than the panel's own
8636
+ * `scrollHeight`. This is necessary because children with their own scroll
8637
+ * context absorb overflow, which would make the panel's `scrollHeight` equal
8638
+ * its `clientHeight` (creating a feedback loop where the height shrinks by
8639
+ * the border size each cycle).
8640
+ *
8641
+ * Relies on the scroll area not having `flex-grow` in fit mode (controlled
8642
+ * by the `fillHeight` CVA variant on the scroll area element). Without this,
8643
+ * a flex-grown scroll area at expanded height would report its stretched
8644
+ * `clientHeight` as `scrollHeight`, making the measured fit height equal
8645
+ * the expanded height.
8646
+ *
8647
+ * The initial measurement sets the height directly. Subsequent
8648
+ * ResizeObserver callbacks only allow the height to increase, preventing
8649
+ * stacking overrides or other transient constraints from permanently
8650
+ * reducing the measured height.
8651
+ *
8652
+ * Accepts the DOM element directly (rather than a RefObject) so that the
8653
+ * layoutEffect re-runs whenever the element becomes available — critical
8654
+ * because FloatingPortal defers its first render, meaning the element is
8655
+ * null on the first layout pass and only non-null after FloatingPortal's
8656
+ * own layoutEffect fires and the Card mounts.
8657
+ */
8658
+ const useSheetFitHeight = (element, sizingMode, shouldRender) => {
8659
+ const [fitHeight, setFitHeight] = react.useState(0);
8660
+ react.useLayoutEffect(() => {
8661
+ if (sizingMode !== "fit" || !shouldRender || !element)
8662
+ return undefined;
8663
+ const measureChildren = () => {
8664
+ let total = 0;
8665
+ for (let i = 0; i < element.children.length; i++) {
8666
+ total += element.children[i]?.scrollHeight ?? 0;
8667
+ }
8668
+ const style = getComputedStyle(element);
8669
+ total += parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
8670
+ return total;
8671
+ };
8672
+ let isInitialObservation = true;
8673
+ const observer = new ResizeObserver(() => {
8674
+ const height = measureChildren();
8675
+ if (isInitialObservation) {
8676
+ isInitialObservation = false;
8677
+ setFitHeight(height);
8678
+ }
8679
+ else {
8680
+ setFitHeight(prev => Math.max(prev, height));
8681
+ }
8682
+ });
8683
+ observer.observe(element);
8684
+ return () => observer.disconnect();
8685
+ }, [sizingMode, shouldRender, element]);
8686
+ // Return 0 when the panel element is not mounted so that fitHeightReady stays
8687
+ // false until the panel is actually in the DOM. Without this, a stale fitHeight
8688
+ // from a previous open would make fitHeightReady=true immediately on re-open,
8689
+ // causing SET_VISUALLY_OPEN to dispatch before the browser has painted the
8690
+ // initial translateY(100%) baseline — resulting in no visible animation
8691
+ // (sheet just appears at the open position) on subsequent opens.
8692
+ return element !== null ? fitHeight : 0;
8693
+ };
8694
+
8695
+ /**
8696
+ * Consolidates container/panel measurement, docked/fit height tracking,
8697
+ * the `onGeometryChange` feedback loop, and all derived snap geometry that Sheet
8698
+ * needs for gestures and CSS sizing.
8699
+ */
8700
+ const useSheetMeasurements = ({ shouldRender, state, dockingEnabled, snapping, externalRef, onGeometryChange, onSnap, }) => {
8701
+ const { geometry: containerGeometry, ref: containerRef } = useMeasure();
8702
+ const containerHeight = containerGeometry?.height ?? 0;
8703
+ const { ref: panelRef, element: panelEl } = useMeasure({ skip: !shouldRender });
8704
+ const mergedPanelRef = useMergeRefs([panelRef, externalRef]);
8705
+ const panelRefObj = react.useRef(null);
8706
+ react.useLayoutEffect(() => {
8707
+ panelRefObj.current = panelEl ?? null;
8708
+ }, [panelEl]);
8709
+ const lastMeasuredDockedHeight = useDockedContentHeight(panelEl, shouldRender, state.sizingMode);
8710
+ const fitHeight = useSheetFitHeight(panelEl, state.sizingMode, shouldRender);
8711
+ const fitHeightReady = state.sizingMode !== "fit" || fitHeight > 0;
8712
+ react.useEffect(() => {
8713
+ onGeometryChange({ containerHeight, fitHeight, dockedHeight: lastMeasuredDockedHeight }, dockingEnabled);
8714
+ }, [onGeometryChange, containerHeight, fitHeight, lastMeasuredDockedHeight, dockingEnabled]);
8715
+ const maxSheetHeightPx = Math.max(0, containerHeight - FULL_HEIGHT_TOP_MARGIN_PX);
8716
+ const cappedFitHeight = containerHeight > 0 && fitHeight > 0 ? Math.min(fitHeight, maxSheetHeightPx) : fitHeight;
8717
+ const activeSnapHeightPx = computeActiveSnapHeight(state.sizingMode, cappedFitHeight, state.levelHeights, state.level, state.effectiveDockedHeight);
8718
+ const gestureSnapHeights = react.useMemo(() => computeGestureSnapHeights(snapping, state.sizingMode, cappedFitHeight, state.levelHeights, dockingEnabled, state.effectiveDockedHeight), [snapping, state.sizingMode, cappedFitHeight, state.levelHeights, dockingEnabled, state.effectiveDockedHeight]);
8719
+ const handleGestureSnap = react.useCallback((heightPx) => {
8720
+ onSnap(heightPx, state.sizingMode === "fit" && fitHeight <= 0);
8721
+ }, [onSnap, state.sizingMode, fitHeight]);
8722
+ return react.useMemo(() => ({
8723
+ containerRef,
8724
+ mergedPanelRef,
8725
+ panelRefObj,
8726
+ panelEl: panelEl ?? null,
8727
+ containerHeight,
8728
+ activeSnapHeightPx,
8729
+ gestureSnapHeights,
8730
+ cappedFitHeight,
8731
+ fitHeightReady,
8732
+ fitHeight,
8733
+ handleGestureSnap,
8734
+ }), [
8735
+ containerRef,
8736
+ mergedPanelRef,
8737
+ panelRefObj,
8738
+ panelEl,
8739
+ containerHeight,
8740
+ activeSnapHeightPx,
8741
+ gestureSnapHeights,
8742
+ cappedFitHeight,
8743
+ fitHeightReady,
8744
+ fitHeight,
8745
+ handleGestureSnap,
8746
+ ]);
8747
+ };
8748
+
8749
+ /**
8750
+ * Blocks scrolling on the document and compensates for scrollbar width to prevent layout shift.
8751
+ * Uses scrollbar-gutter: stable to reserve space for the scrollbar when hiding overflow,
8752
+ * but only if a scrollbar was actually visible before blocking.
8753
+ * This only has an effect with classic scrollbars - overlay scrollbars (macOS default) are unaffected.
8754
+ * Returns the original styles so they can be restored later.
8755
+ *
8756
+ * @returns {OriginalStyles} The original styles before blocking
8757
+ */
8758
+ const blockDocumentScroll = () => {
8759
+ const { body } = document;
8760
+ const html = document.documentElement;
8761
+ // Check if there's a visible scrollbar before we hide it
8762
+ // The scrollbar can be on either <html> or <body> depending on CSS setup
8763
+ // (e.g., Storybook sets overflow:hidden on html and overflow:auto on body)
8764
+ // Check html scrollbar: window.innerWidth includes scrollbar, clientWidth doesn't
8765
+ const htmlScrollbarWidth = window.innerWidth - html.clientWidth;
8766
+ // Check body scrollbar: offsetWidth includes border+scrollbar, clientWidth excludes both
8767
+ const bodyStyle = window.getComputedStyle(body);
8768
+ const bodyBorderLeft = parseInt(bodyStyle.borderLeftWidth) || 0;
8769
+ const bodyBorderRight = parseInt(bodyStyle.borderRightWidth) || 0;
8770
+ const bodyScrollbarWidth = body.offsetWidth - bodyBorderLeft - bodyBorderRight - body.clientWidth;
8771
+ // Use whichever scrollbar is present
8772
+ const hasVisibleScrollbar = htmlScrollbarWidth > 0 || bodyScrollbarWidth > 0;
8773
+ // Store original values before modifying
8774
+ const originalStyles = {
8775
+ html: {
8776
+ position: html.style.position,
8777
+ overflow: html.style.overflow,
8778
+ },
8779
+ body: {
8780
+ position: body.style.position,
8781
+ overflow: body.style.overflow,
8782
+ scrollbarGutter: body.style.scrollbarGutter,
8783
+ },
8784
+ };
8785
+ // Block scroll on both html and body for cross-browser compatibility
8786
+ html.style.position = "relative";
8787
+ html.style.overflow = "hidden";
8788
+ body.style.position = "relative";
8789
+ body.style.overflow = "hidden";
8790
+ // Apply scrollbar-gutter on body (where the scrollbar typically lives when content scrolls)
8791
+ // This reserves space for the scrollbar even when overflow is hidden, preventing layout shift.
8792
+ // Only has effect with classic scrollbars - overlay scrollbars (macOS default) are unaffected.
8793
+ if (hasVisibleScrollbar) {
8794
+ body.style.scrollbarGutter = "stable";
8795
+ }
8796
+ return originalStyles;
8797
+ };
8798
+ /**
8799
+ * Restores document scrolling by restoring the provided original styles.
8800
+ *
8801
+ * @param originalStyles - The original styles to restore
8802
+ */
8803
+ const restoreDocumentScroll = (originalStyles) => {
8804
+ const { body } = document;
8805
+ const html = document.documentElement;
8806
+ // Restore original values instead of just clearing
8807
+ if (originalStyles.html) {
8808
+ html.style.position = originalStyles.html.position;
8809
+ html.style.overflow = originalStyles.html.overflow;
8810
+ }
8811
+ if (originalStyles.body) {
8812
+ body.style.position = originalStyles.body.position;
8813
+ body.style.overflow = originalStyles.body.overflow;
8814
+ body.style.scrollbarGutter = originalStyles.body.scrollbarGutter;
8815
+ }
8816
+ };
8817
+ /**
8818
+ * Blocks scrolling on a custom container element.
8819
+ * Uses scrollbar-gutter: stable to reserve space for the scrollbar when hiding overflow,
8820
+ * but only if a scrollbar was actually visible before blocking.
8821
+ * This only has an effect with classic scrollbars - overlay scrollbars (macOS default) are unaffected.
8822
+ * Returns the original styles so they can be restored later.
8823
+ *
8824
+ * @param container - The container element to block scroll on
8825
+ * @returns {OriginalStyles} The original styles before blocking
8826
+ */
8827
+ const blockContainerScroll = (container) => {
8828
+ // Check if there's a visible scrollbar before we hide it
8829
+ // offsetWidth includes border + scrollbar, clientWidth excludes both
8830
+ // We need to subtract borders to isolate the scrollbar width
8831
+ const style = window.getComputedStyle(container);
8832
+ const borderLeft = parseInt(style.borderLeftWidth) || 0;
8833
+ const borderRight = parseInt(style.borderRightWidth) || 0;
8834
+ const scrollbarWidth = container.offsetWidth - borderLeft - borderRight - container.clientWidth;
8835
+ const hasVisibleScrollbar = scrollbarWidth > 0;
8836
+ const originalStyles = {
8837
+ container: {
8838
+ overflow: container.style.overflow,
8839
+ scrollbarGutter: container.style.scrollbarGutter,
8840
+ },
8841
+ };
8842
+ container.style.overflow = "hidden";
8843
+ // Only add scrollbar-gutter if there was a visible scrollbar to preserve space for
8844
+ // This prevents adding unnecessary space when content doesn't overflow
8845
+ if (hasVisibleScrollbar) {
8846
+ container.style.scrollbarGutter = "stable";
8847
+ }
8848
+ return originalStyles;
8849
+ };
8850
+ /**
8851
+ * Restores container scrolling by restoring the provided original styles.
8852
+ *
8853
+ * @param container - The container element to restore scroll on
8854
+ * @param originalStyles - The original styles to restore
8855
+ */
8856
+ const restoreContainerScroll = (container, originalStyles) => {
8857
+ if (originalStyles.container) {
8858
+ container.style.overflow = originalStyles.container.overflow;
8859
+ container.style.scrollbarGutter = originalStyles.container.scrollbarGutter;
8860
+ }
8861
+ };
8862
+ /**
8863
+ * Hook that provides scroll blocking functionality.
8864
+ * This properly accounts for existing body padding to prevent layout shifts.
8865
+ *
8866
+ * Each instance gets its own stored original styles via refs, preventing
8867
+ * conflicts when multiple components are used on the same page. The hook also ensures
8868
+ * cleanup on unmount if scroll is still blocked.
8869
+ *
8870
+ * @param scrollContainer - The DOM element whose scroll should be blocked. Defaults to document.body.
8871
+ * @returns {{blockScroll: () => void, restoreScroll: () => void}} Object containing blockScroll and restoreScroll functions
8872
+ */
8873
+ const useScrollBlock = (scrollContainer = typeof document !== "undefined" ? document.body : null // default to document.body if no scroll container is provided
8874
+ ) => {
8875
+ const originalStylesRef = react.useRef(null);
8876
+ const isBlockedRef = react.useRef(false);
8877
+ /**
8878
+ * Blocks scrolling and stores original styles for restoration.
8879
+ * Blocks the document (body/html) if scroll container is the document body,
8880
+ * or blocks the custom container if a custom container is provided.
8881
+ */
8882
+ const blockScroll = react.useCallback(() => {
8883
+ if (isBlockedRef.current || !scrollContainer) {
8884
+ return; // Already blocked or no scroll container
8885
+ }
8886
+ if (scrollContainer === document.body) {
8887
+ originalStylesRef.current = blockDocumentScroll();
8888
+ }
8889
+ else {
8890
+ originalStylesRef.current = blockContainerScroll(scrollContainer);
8891
+ }
8892
+ isBlockedRef.current = true;
8893
+ }, [scrollContainer]);
8894
+ /**
8895
+ * Restores scrolling using the previously stored original styles.
8896
+ */
8897
+ const restoreScroll = react.useCallback(() => {
8898
+ if (!isBlockedRef.current || !scrollContainer || !originalStylesRef.current) {
8899
+ return;
8900
+ }
8901
+ if (scrollContainer === document.body) {
8902
+ restoreDocumentScroll(originalStylesRef.current);
8903
+ }
8904
+ else {
8905
+ restoreContainerScroll(scrollContainer, originalStylesRef.current);
8906
+ }
8907
+ originalStylesRef.current = null;
8908
+ isBlockedRef.current = false;
8909
+ }, [scrollContainer]);
8910
+ // Cleanup: restore scroll if component unmounts while scroll is blocked
8911
+ react.useEffect(() => {
8912
+ return () => {
8913
+ if (isBlockedRef.current && scrollContainer && originalStylesRef.current) {
8914
+ if (scrollContainer === document.body) {
8915
+ restoreDocumentScroll(originalStylesRef.current);
8916
+ }
8917
+ else {
8918
+ restoreContainerScroll(scrollContainer, originalStylesRef.current);
8919
+ }
8920
+ isBlockedRef.current = false;
8921
+ }
8922
+ };
8923
+ }, [scrollContainer]);
8924
+ return react.useMemo(() => ({ blockScroll, restoreScroll }), [blockScroll, restoreScroll]);
8925
+ };
8926
+
8927
+ /**
8928
+ * Manages scrollbar and separator-line visibility during sheet motion (drag
8929
+ * and CSS height transitions), freezing both to the state captured at motion
8930
+ * start to avoid visual flashing.
8931
+ *
8932
+ * Freeze rules:
8933
+ * - **Was overflowing at motion start** — scrollbar is forced visible
8934
+ * (`overflow-y: scroll`) and separator stays opaque throughout the motion.
8935
+ * - **Was NOT overflowing at motion start** — scrollbar is hidden via
8936
+ * `useScrollBlock` (overflow hidden + scrollbar-gutter to prevent reflow)
8937
+ * and separator stays transparent throughout the motion.
8938
+ * - **Was docked at motion start** — always treated as "not overflowing"
8939
+ * because the scroll area is not visible in docked state. Overflow is
8940
+ * hidden directly (no scrollbar-gutter) so no space is reserved for a
8941
+ * scrollbar that was never visible.
8942
+ *
8943
+ * At rest, overflow is detected via ResizeObserver + MutationObserver and
8944
+ * the separator's `style.opacity` is toggled (CSS transition on the element
8945
+ * provides the fade).
8946
+ */
8947
+ const useSheetMotionOverflow = ({ panelEl, isDragging, scrollAreaEl, separatorEl, isDocked, isClosing, }) => {
8948
+ const isTransitioningRef = react.useRef(false);
8949
+ const isDraggingRef = react.useRef(isDragging);
8950
+ const isMotionActiveRef = react.useRef(false);
8951
+ const wasOverflowingAtStartRef = react.useRef(false);
8952
+ const frozeFromDockedRef = react.useRef(false);
8953
+ const originalOverflowYRef = react.useRef("");
8954
+ const isDockedRef = react.useRef(isDocked);
8955
+ const isClosingRef = react.useRef(isClosing);
8956
+ const settledDockedRef = react.useRef(isDocked);
8957
+ const { blockScroll, restoreScroll } = useScrollBlock(scrollAreaEl);
8958
+ const blockScrollRef = react.useRef(blockScroll);
8959
+ const restoreScrollRef = react.useRef(restoreScroll);
8960
+ const scrollAreaElRef = react.useRef(scrollAreaEl);
8961
+ const separatorElRef = react.useRef(separatorEl);
8962
+ const checkOverflow = react.useCallback(() => {
8963
+ const el = scrollAreaElRef.current;
8964
+ if (!el)
8965
+ return false;
8966
+ return el.scrollHeight > el.clientHeight;
8967
+ }, []);
8968
+ const syncSeparator = react.useCallback(() => {
8969
+ if (isMotionActiveRef.current)
8970
+ return;
8971
+ const sep = separatorElRef.current;
8972
+ if (!sep)
8973
+ return;
8974
+ sep.style.opacity = checkOverflow() ? "1" : "";
8975
+ }, [checkOverflow]);
8976
+ const freeze = react.useCallback((forceHideOverflow = false) => {
8977
+ if (isMotionActiveRef.current)
8978
+ return;
8979
+ isMotionActiveRef.current = true;
8980
+ const fromDocked = settledDockedRef.current;
8981
+ const wasOverflowing = forceHideOverflow ? false : fromDocked ? false : checkOverflow();
8982
+ wasOverflowingAtStartRef.current = wasOverflowing;
8983
+ frozeFromDockedRef.current = fromDocked;
8984
+ const area = scrollAreaElRef.current;
8985
+ const sep = separatorElRef.current;
8986
+ if (wasOverflowing) {
8987
+ if (area) {
8988
+ originalOverflowYRef.current = area.style.overflowY;
8989
+ area.style.overflowY = "scroll";
8990
+ }
8991
+ if (sep)
8992
+ sep.style.opacity = "1";
8993
+ }
8994
+ else if (fromDocked) {
8995
+ if (area) {
8996
+ originalOverflowYRef.current = area.style.overflowY;
8997
+ area.style.overflowY = "hidden";
8998
+ }
8999
+ if (sep)
9000
+ sep.style.opacity = "";
9001
+ }
9002
+ else {
9003
+ blockScrollRef.current();
9004
+ if (sep)
9005
+ sep.style.opacity = "";
9006
+ }
9007
+ }, [checkOverflow]);
9008
+ const unfreeze = react.useCallback(() => {
9009
+ if (!isMotionActiveRef.current)
9010
+ return;
9011
+ isMotionActiveRef.current = false;
9012
+ const area = scrollAreaElRef.current;
9013
+ if (wasOverflowingAtStartRef.current || frozeFromDockedRef.current) {
9014
+ if (area) {
9015
+ area.style.overflowY = originalOverflowYRef.current;
9016
+ originalOverflowYRef.current = "";
9017
+ }
9018
+ }
9019
+ else {
9020
+ restoreScrollRef.current();
9021
+ }
9022
+ settledDockedRef.current = isDockedRef.current;
9023
+ syncSeparator();
9024
+ }, [syncSeparator]);
9025
+ react.useLayoutEffect(() => {
9026
+ const wasDocked = isDockedRef.current;
9027
+ const wasClosing = isClosingRef.current;
9028
+ blockScrollRef.current = blockScroll;
9029
+ restoreScrollRef.current = restoreScroll;
9030
+ scrollAreaElRef.current = scrollAreaEl;
9031
+ separatorElRef.current = separatorEl;
9032
+ isDockedRef.current = isDocked;
9033
+ isClosingRef.current = isClosing;
9034
+ if (wasDocked && !isDocked) {
9035
+ freeze();
9036
+ }
9037
+ if (!wasClosing && isClosing) {
9038
+ freeze(true);
9039
+ }
9040
+ syncSeparator();
9041
+ });
9042
+ react.useEffect(() => {
9043
+ isDraggingRef.current = isDragging;
9044
+ if (isDragging) {
9045
+ freeze();
9046
+ }
9047
+ else if (!isTransitioningRef.current) {
9048
+ unfreeze();
9049
+ }
9050
+ }, [isDragging, freeze, unfreeze]);
9051
+ react.useEffect(() => {
9052
+ if (!panelEl)
9053
+ return;
9054
+ const onStart = (e) => {
9055
+ if (e.target !== panelEl || e.propertyName !== "height")
9056
+ return;
9057
+ isTransitioningRef.current = true;
9058
+ freeze();
9059
+ };
9060
+ const onEnd = (e) => {
9061
+ if (e.target !== panelEl || e.propertyName !== "height")
9062
+ return;
9063
+ isTransitioningRef.current = false;
9064
+ if (!isDraggingRef.current)
9065
+ unfreeze();
9066
+ };
9067
+ const onCancel = (e) => {
9068
+ if (e.target !== panelEl || e.propertyName !== "height")
9069
+ return;
9070
+ isTransitioningRef.current = false;
9071
+ if (!isDraggingRef.current)
9072
+ unfreeze();
9073
+ };
9074
+ panelEl.addEventListener("transitionstart", onStart);
9075
+ panelEl.addEventListener("transitionend", onEnd);
9076
+ panelEl.addEventListener("transitioncancel", onCancel);
9077
+ return () => {
9078
+ panelEl.removeEventListener("transitionstart", onStart);
9079
+ panelEl.removeEventListener("transitionend", onEnd);
9080
+ panelEl.removeEventListener("transitioncancel", onCancel);
9081
+ };
9082
+ }, [panelEl, freeze, unfreeze]);
9083
+ react.useEffect(() => {
9084
+ if (!scrollAreaEl)
9085
+ return;
9086
+ const update = () => {
9087
+ syncSeparator();
9088
+ };
9089
+ update();
9090
+ const ro = new ResizeObserver(update);
9091
+ ro.observe(scrollAreaEl);
9092
+ const mo = new MutationObserver(update);
9093
+ mo.observe(scrollAreaEl, { childList: true, subtree: true, characterData: true });
9094
+ scrollAreaEl.addEventListener("scroll", update, { passive: true });
9095
+ return () => {
9096
+ ro.disconnect();
9097
+ mo.disconnect();
9098
+ scrollAreaEl.removeEventListener("scroll", update);
9099
+ };
9100
+ }, [scrollAreaEl, syncSeparator]);
9101
+ };
9102
+
9103
+ /**
9104
+ * Container-scoped bottom sheet with adaptive snap levels and gesture support.
9105
+ *
9106
+ * Use Sheet for contextual surfaces that slide up from the bottom of a
9107
+ * container -- action menus, detail panels, element settings, or filters.
9108
+ * Every Sheet renders via Portal into a required `container` element.
9109
+ * On small screens, consider using Sheet instead of a Popover for better
9110
+ * touch UX. For app-level blocking dialogs, use Modal instead (which
9111
+ * automatically renders as a Sheet on small screens).
9112
+ *
9113
+ * When `variant="modal"`, the sheet behaves as a dialog: it renders a
9114
+ * dimming backdrop, sets `role="dialog"` and `aria-modal` on the panel,
9115
+ * and traps keyboard focus via FloatingFocusManager. Pass
9116
+ * `trapFocus={false}` when a parent component provides its own focus
9117
+ * management (e.g. Modal in sheet mode).
9118
+ *
9119
+ * Snap levels are adaptive: the Sheet measures the container and creates
9120
+ * fewer stops in shorter containers. Consumers navigate with directional
9121
+ * methods (`snapUp`, `snapDown`, `expand`, `collapse`, `dock`) instead of
9122
+ * naming specific snap points.
9123
+ *
9124
+ * The outermost wrapper sets `container-type: size` so cqh/cqw units
9125
+ * resolve against the container's dimensions. Open/close animations use
9126
+ * CSS transitions on transform; the component stays mounted during the
9127
+ * close animation and unmounts after the transition completes.
9128
+ */
9129
+ const Sheet = ({ isOpen, state, snap, onGeometryChange, onSnap, onClickHandle, onCloseGesture, floatingUi, ref, anchor = "center", snapping = true, resizable = true, variant = "default", trapFocus = true, container, dockedContent, className, "data-testid": dataTestId, onCloseComplete, entryAnimation = "subsequent", children, }) => {
9130
+ const isFirstRender = useIsFirstRender();
9131
+ const skipEntryAnimation = entryAnimation === "never" || (entryAnimation === "subsequent" && isFirstRender);
9132
+ const effectiveSnapping = resizable && snapping;
9133
+ const [animState, animDispatch] = react.useReducer(sheetAnimationReducer, INITIAL_ANIMATION_STATE);
9134
+ if (isOpen !== animState.prevIsOpen) {
9135
+ animDispatch({
9136
+ type: "SYNC_OPEN",
9137
+ isOpen,
9138
+ skipEntryAnimation,
9139
+ });
9140
+ }
9141
+ const measurements = useSheetMeasurements({
9142
+ shouldRender: animState.shouldRender,
9143
+ state,
9144
+ dockingEnabled: dockedContent !== undefined,
9145
+ snapping: effectiveSnapping,
9146
+ externalRef: ref,
9147
+ onGeometryChange,
9148
+ onSnap,
9149
+ });
9150
+ const { containerRef, mergedPanelRef, panelRefObj, panelEl, containerHeight, activeSnapHeightPx, gestureSnapHeights, cappedFitHeight, fitHeightReady, fitHeight, handleGestureSnap, } = measurements;
9151
+ const gestures = useSheetGestures({
9152
+ activeSnapHeight: activeSnapHeightPx,
9153
+ containerHeight,
9154
+ enabled: animState.shouldRender && containerHeight > 0 && effectiveSnapping,
9155
+ onClose: onCloseGesture,
9156
+ onSnap: handleGestureSnap,
9157
+ sheetRef: panelRefObj,
9158
+ snapHeights: gestureSnapHeights,
9159
+ });
9160
+ const [scrollAreaEl, setScrollAreaEl] = react.useState(null);
9161
+ const [separatorEl, setSeparatorEl] = react.useState(null);
9162
+ useSheetMotionOverflow({
9163
+ panelEl,
9164
+ isDragging: gestures.isDragging,
9165
+ scrollAreaEl,
9166
+ separatorEl,
9167
+ isDocked: state.sizingMode === "docked",
9168
+ isClosing: animState.closePhase !== "idle",
9169
+ });
9170
+ const { onMouseEnter, onMouseLeave } = useSheetDismissIntent({
9171
+ closable: onCloseGesture !== undefined,
9172
+ closePhase: animState.closePhase,
9173
+ panelRef: panelRefObj,
9174
+ });
9175
+ const shouldTrapFocus = variant === "modal" && trapFocus && floatingUi !== undefined;
9176
+ const panelRefWithFloating = react$1.useMergeRefs([mergedPanelRef, floatingUi?.refs.setFloating ?? null]);
9177
+ const handleClick = react.useCallback((e) => {
9178
+ const mod = e.metaKey || e.ctrlKey;
9179
+ if (mod && e.shiftKey) {
9180
+ onCloseGesture?.();
9181
+ return;
9182
+ }
9183
+ if (mod) {
9184
+ snap.fit();
9185
+ return;
9186
+ }
9187
+ if (e.altKey) {
9188
+ snap.dock();
9189
+ return;
9190
+ }
9191
+ if (e.shiftKey) {
9192
+ snap.expand();
9193
+ return;
9194
+ }
9195
+ if (state.sizingMode === "docked") {
9196
+ snap.fit();
9197
+ return;
9198
+ }
9199
+ onClickHandle();
9200
+ }, [onClickHandle, snap, onCloseGesture, state.sizingMode]);
9201
+ const { handleTransitionEnd } = useSheetLifecycle({
9202
+ isOpen,
9203
+ entryAnimation: entryAnimation !== "never",
9204
+ visuallyOpen: animState.visuallyOpen,
9205
+ variant,
9206
+ container,
9207
+ panelRef: panelRefObj,
9208
+ panelEl,
9209
+ dispatch: animDispatch,
9210
+ onCloseComplete,
9211
+ fitHeightReady,
9212
+ });
9213
+ const showOverlay = variant === "modal" && state.sizingMode !== "docked" && animState.visuallyOpen;
9214
+ const fitHeightMeasured = state.sizingMode === "fit" && fitHeight > 0;
9215
+ const snapHeightCss = state.sizingMode === "docked"
9216
+ ? `${state.effectiveDockedHeight}px`
9217
+ : fitHeightMeasured
9218
+ ? `${cappedFitHeight}px`
9219
+ : getSnapLevelCssHeight(state.level, state.totalLevels, dockedContent !== undefined, state.effectiveDockedHeight);
9220
+ const fitMaxHeight = state.sizingMode === "fit" ? `calc(100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px)` : undefined;
9221
+ if (!animState.shouldRender)
9222
+ return null;
9223
+ if (!container) {
9224
+ return null;
9225
+ }
9226
+ const gestureHandlers = effectiveSnapping
9227
+ ? {
9228
+ onPointerDown: gestures.onPointerDown,
9229
+ onPointerMove: gestures.onPointerMove,
9230
+ onPointerUp: gestures.onPointerUp,
9231
+ onLostPointerCapture: gestures.onLostPointerCapture,
9232
+ }
9233
+ : {};
9234
+ const panel = (jsxRuntime.jsxs(Card, { ...(shouldTrapFocus === true ? { "aria-modal": true, role: "dialog" } : {}), ...(floatingUi?.getFloatingProps() ?? {}), className: cvaSheetPanel({ anchor, variant, className }), "data-testid": dataTestId, onTransitionEnd: handleTransitionEnd, ref: panelRefWithFloating, style: getSheetPanelStyle({
9235
+ autoHeight: state.sizingMode === "fit" && !fitHeightMeasured,
9236
+ closePhase: animState.closePhase,
9237
+ isDragging: gestures.isDragging,
9238
+ isOpen: animState.visuallyOpen,
9239
+ maxHeight: fitMaxHeight,
9240
+ snapHeight: snapHeightCss,
9241
+ suppressTransition: skipEntryAnimation && animState.visuallyOpen,
9242
+ variant,
9243
+ }), children: [resizable ? (jsxRuntime.jsx(SheetHandle, { "data-testid": dataTestId !== undefined ? `${dataTestId}-handle` : undefined, isDragging: gestures.isDragging, onClick: handleClick, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, ...gestureHandlers })) : null, jsxRuntime.jsx("div", { className: "h-px shrink-0 bg-neutral-200 opacity-0 transition-opacity duration-200", ref: setSeparatorEl }), jsxRuntime.jsx("div", { className: cvaSheetScrollArea({ fillHeight: state.sizingMode !== "fit" }), "data-sheet-scroll-area": true, ref: setScrollAreaEl, children: state.sizingMode === "docked" ? dockedContent : children })] }));
9244
+ return (jsxRuntime.jsx(Portal, { root: container, children: jsxRuntime.jsxs("div", { className: cvaSheetContainer({
9245
+ docked: state.sizingMode === "docked",
9246
+ }), "data-testid": dataTestId !== undefined ? `${dataTestId}-container` : undefined, ref: containerRef, children: [jsxRuntime.jsx(SheetOverlay, { "data-testid": dataTestId !== undefined ? `${dataTestId}-overlay` : undefined, visible: showOverlay }), shouldTrapFocus === true ? (jsxRuntime.jsx(react$1.FloatingFocusManager, { context: floatingUi.context, children: panel })) : (panel)] }) }));
9247
+ };
9248
+
9249
+ /** Resolves DismissOptions with defaults (all true when omitted). */
9250
+ const DEFAULT_DISMISS_OPTIONS = {
9251
+ escapeKey: true,
9252
+ outsidePress: true,
9253
+ gesture: true,
9254
+ };
9255
+ /** Merges caller-supplied DismissOptions with defaults, returning a fully-resolved options object. */
9256
+ function resolveDismissOptions(dismiss) {
9257
+ return {
9258
+ escapeKey: dismiss?.escapeKey ?? DEFAULT_DISMISS_OPTIONS.escapeKey,
9259
+ outsidePress: dismiss?.outsidePress ?? DEFAULT_DISMISS_OPTIONS.outsidePress,
9260
+ gesture: dismiss?.gesture ?? DEFAULT_DISMISS_OPTIONS.gesture,
9261
+ };
9262
+ }
9263
+
9264
+ const INITIAL_SNAP_STATE = {
9265
+ currentLevel: 0,
9266
+ sizingMode: "level",
9267
+ initialSizeApplied: false,
9268
+ prevIsOpen: false,
9269
+ totalLevels: 0,
9270
+ dockingEnabled: false,
9271
+ levelHeights: [],
9272
+ effectiveDockedHeight: 0,
9273
+ fitHeight: 0,
9274
+ };
9275
+ const findLevelByHeight = (heightPx, levelHeights) => {
9276
+ let closestIndex = 0;
9277
+ let closestDistance = Infinity;
9278
+ for (let i = 0; i < levelHeights.length; i++) {
9279
+ const h = levelHeights[i];
9280
+ if (h === undefined)
9281
+ continue;
9282
+ const distance = Math.abs(h - heightPx);
9283
+ if (distance < closestDistance) {
9284
+ closestDistance = distance;
9285
+ closestIndex = i;
9286
+ }
9287
+ }
9288
+ return closestIndex;
9289
+ };
9290
+ const withSnapSync = (state, currentLevel, sizingMode) => ({
9291
+ ...state,
9292
+ currentLevel,
9293
+ sizingMode,
9294
+ });
9295
+ /** Reducer managing snap-level positioning and measurement-derived state. */
9296
+ const snapReducer = (state, action) => {
9297
+ switch (action.type) {
9298
+ case "SYNC_OPEN": {
9299
+ if (action.isOpen === state.prevIsOpen)
9300
+ return state;
9301
+ if (action.isOpen) {
9302
+ return {
9303
+ ...state,
9304
+ prevIsOpen: true,
9305
+ currentLevel: 0,
9306
+ sizingMode: "level",
9307
+ initialSizeApplied: false,
9308
+ };
9309
+ }
9310
+ return {
9311
+ ...state,
9312
+ prevIsOpen: false,
9313
+ initialSizeApplied: false,
9314
+ };
9315
+ }
9316
+ case "SYNC_MEASUREMENTS": {
9317
+ return {
9318
+ ...state,
9319
+ totalLevels: action.levelHeights.length,
9320
+ dockingEnabled: action.measurements.dockingEnabled,
9321
+ levelHeights: action.levelHeights,
9322
+ effectiveDockedHeight: action.measurements.dockedHeight,
9323
+ fitHeight: action.measurements.fitHeight,
9324
+ };
9325
+ }
9326
+ case "APPLY_INITIAL_SIZE": {
9327
+ if (state.initialSizeApplied)
9328
+ return state;
9329
+ if (action.defaultSize === "fit") {
9330
+ return { ...state, initialSizeApplied: true, sizingMode: "fit" };
9331
+ }
9332
+ if (action.defaultSize === "collapsed") {
9333
+ return withSnapSync({ ...state, initialSizeApplied: true }, 0, "level");
9334
+ }
9335
+ if (action.defaultSize === "expanded") {
9336
+ return withSnapSync({ ...state, initialSizeApplied: true }, state.totalLevels - 1, "level");
9337
+ }
9338
+ if (state.dockingEnabled) {
9339
+ return withSnapSync({ ...state, initialSizeApplied: true }, 0, "docked");
9340
+ }
9341
+ return state;
9342
+ }
9343
+ case "SNAP_UP": {
9344
+ if (state.sizingMode === "docked") {
9345
+ return withSnapSync(state, 0, "level");
9346
+ }
9347
+ const nextUp = Math.min(state.currentLevel + 1, state.totalLevels - 1);
9348
+ return nextUp === state.currentLevel && state.sizingMode !== "fit" ? state : withSnapSync(state, nextUp, "level");
9349
+ }
9350
+ case "SNAP_DOWN": {
9351
+ if (state.sizingMode === "docked")
9352
+ return state;
9353
+ if (state.currentLevel === 0 && state.dockingEnabled) {
9354
+ return withSnapSync(state, state.currentLevel, "docked");
9355
+ }
9356
+ const nextDown = Math.max(state.currentLevel - 1, 0);
9357
+ return nextDown === state.currentLevel && state.sizingMode !== "fit"
9358
+ ? state
9359
+ : withSnapSync(state, nextDown, "level");
9360
+ }
9361
+ case "EXPAND":
9362
+ return withSnapSync(state, state.totalLevels - 1, "level");
9363
+ case "COLLAPSE":
9364
+ return withSnapSync(state, 0, "level");
9365
+ case "DOCK":
9366
+ return state.dockingEnabled ? withSnapSync(state, state.currentLevel, "docked") : state;
9367
+ case "FIT":
9368
+ return { ...state, sizingMode: "fit" };
9369
+ case "GESTURE_SNAP": {
9370
+ if (state.dockingEnabled && Math.abs(action.heightPx - state.effectiveDockedHeight) < 1) {
9371
+ return withSnapSync(state, state.currentLevel, "docked");
9372
+ }
9373
+ if (state.sizingMode === "fit" && !action.useLevelSnap) {
9374
+ if (state.fitHeight > 0 && Math.abs(action.heightPx - state.fitHeight) < 1) {
9375
+ return state;
9376
+ }
9377
+ const fitLevel = findLevelByHeight(action.heightPx, state.levelHeights);
9378
+ return withSnapSync(state, fitLevel, "level");
9379
+ }
9380
+ const level = findLevelByHeight(action.heightPx, state.levelHeights);
9381
+ return withSnapSync(state, level, "level");
9382
+ }
9383
+ case "CYCLE_SNAP": {
9384
+ if (state.sizingMode === "fit")
9385
+ return state;
9386
+ if (state.sizingMode === "docked")
9387
+ return withSnapSync(state, 0, "level");
9388
+ const nextCycle = state.currentLevel + 1;
9389
+ if (nextCycle >= state.totalLevels) {
9390
+ return state.dockingEnabled
9391
+ ? withSnapSync(state, state.currentLevel, "docked")
9392
+ : withSnapSync(state, 0, "level");
9393
+ }
9394
+ return withSnapSync(state, nextCycle, "level");
9395
+ }
9396
+ default:
9397
+ return state;
9398
+ }
9399
+ };
9400
+
9401
+ const buildState = (snapState, dockedHeight) => ({
9402
+ sizingMode: snapState.sizingMode,
9403
+ level: snapState.currentLevel,
9404
+ totalLevels: snapState.totalLevels,
9405
+ isExpanded: snapState.sizingMode === "level" &&
9406
+ snapState.totalLevels > 0 &&
9407
+ snapState.currentLevel === snapState.totalLevels - 1,
9408
+ isCollapsed: snapState.sizingMode === "level" && snapState.currentLevel === 0,
9409
+ dockedHeight,
9410
+ levelHeights: snapState.levelHeights,
9411
+ effectiveDockedHeight: snapState.effectiveDockedHeight,
9412
+ });
9413
+ /**
9414
+ * Composable hook managing snap-level state for the Sheet component.
9415
+ *
9416
+ * Owns the snap reducer, processes measurements from Sheet via `onGeometryChange`,
9417
+ * and provides stable navigation methods. Used by `useSheet` internally and
9418
+ * can be used directly by Modal for independent snap management.
9419
+ */
9420
+ const useSheetSnap = ({ defaultSize, isOpen }) => {
9421
+ const [state, dispatch] = react.useReducer(snapReducer, INITIAL_SNAP_STATE);
9422
+ if (isOpen !== state.prevIsOpen) {
9423
+ dispatch({ type: "SYNC_OPEN", isOpen });
9424
+ }
9425
+ if (isOpen && !state.initialSizeApplied && (state.totalLevels > 0 || defaultSize === "fit")) {
9426
+ dispatch({ type: "APPLY_INITIAL_SIZE", defaultSize });
9427
+ }
9428
+ const dockedHeightForSize = state.dockingEnabled ? state.effectiveDockedHeight : 0;
9429
+ const sheetState = react.useMemo(() => buildState(state, dockedHeightForSize), [state, dockedHeightForSize]);
9430
+ const onGeometryChange = react.useCallback((m, dockingEnabled) => {
9431
+ const effectiveDockedHeight = m.dockedHeight > 0 ? m.dockedHeight : DOCKED_HEIGHT_PX;
9432
+ const levelHeights = computeSnapLevelHeights(m.containerHeight, dockingEnabled, effectiveDockedHeight);
9433
+ dispatch({
9434
+ type: "SYNC_MEASUREMENTS",
9435
+ measurements: {
9436
+ fitHeight: m.fitHeight,
9437
+ dockedHeight: effectiveDockedHeight,
9438
+ dockingEnabled,
9439
+ },
9440
+ levelHeights,
9441
+ });
9442
+ }, []);
9443
+ const onSnap = react.useCallback((heightPx, useLevelSnap) => {
9444
+ dispatch({ type: "GESTURE_SNAP", heightPx, useLevelSnap });
9445
+ }, []);
9446
+ const onClickHandle = react.useCallback(() => {
9447
+ dispatch({ type: "CYCLE_SNAP" });
9448
+ }, []);
9449
+ const snapUp = react.useCallback(() => {
9450
+ dispatch({ type: "SNAP_UP" });
9451
+ }, []);
9452
+ const snapDown = react.useCallback(() => {
9453
+ dispatch({ type: "SNAP_DOWN" });
9454
+ }, []);
9455
+ const expand = react.useCallback(() => {
9456
+ dispatch({ type: "EXPAND" });
9457
+ }, []);
9458
+ const collapse = react.useCallback(() => {
9459
+ dispatch({ type: "COLLAPSE" });
9460
+ }, []);
9461
+ const dock = react.useCallback(() => {
9462
+ dispatch({ type: "DOCK" });
9463
+ }, []);
9464
+ const fit = react.useCallback(() => {
9465
+ dispatch({ type: "FIT" });
9466
+ }, []);
9467
+ const snap = react.useMemo(() => ({ snapUp, snapDown, expand, collapse, dock, fit }), [snapUp, snapDown, expand, collapse, dock, fit]);
9468
+ return react.useMemo(() => ({ state: sheetState, snap, onGeometryChange, onSnap, onClickHandle }), [sheetState, snap, onGeometryChange, onSnap, onClickHandle]);
9469
+ };
9470
+
9471
+ /**
9472
+ * Hook for managing Sheet open/close state and directional snap navigation.
9473
+ *
9474
+ * Consumers navigate via `snap.snapUp()` / `snap.snapDown()` /
9475
+ * `snap.expand()` / `snap.collapse()` / `snap.dock()` instead of
9476
+ * naming specific snap points. The snap state is managed by the composable
9477
+ * `useSheetSnap` hook internally.
9478
+ *
9479
+ * Owns all dismiss handling (ESC, outside-press, gesture) via Floating UI's
9480
+ * `useDismiss`. Each dismiss path goes through `onOpenChange` →
9481
+ * `requestClose` with the correct `CloseReason`, then through
9482
+ * `onBeforeClose` guard, then `handleClose`. The public `close()` is a
9483
+ * no-arg wrapper that tags the reason as `"programmatic"`.
9484
+ *
9485
+ * Outside-press dismiss is only active when `variant="modal"` — the only
9486
+ * variant that renders a backdrop overlay. For `"default"` and `"floating"`
9487
+ * variants, clicks outside the panel do not close the sheet.
9488
+ *
9489
+ * Returns a `floatingUi` object (context, refs, getFloatingProps) that the
9490
+ * Sheet component uses for focus management and dismiss interaction binding,
9491
+ * mirroring `useModal`'s architecture.
9492
+ *
9493
+ * Supports controlled (isOpen) and uncontrolled (defaultOpen) modes, stores
9494
+ * external callbacks in refs for stability.
9495
+ */
9496
+ const useSheet = (props) => {
9497
+ const { isOpen: controlledIsOpen, defaultOpen, onClose, onOpen, onOpenChange, onBeforeClose, dismiss: dismissProp, variant = "default", defaultSize = "fit", onStateChange, onGeometryChange: onGeometryChangeProp, } = props ?? {};
9498
+ const dismiss = react.useMemo(() => resolveDismissOptions(dismissProp), [dismissProp]);
9499
+ const [internalIsOpen, setIsOpen] = react.useState(defaultOpen ?? false);
9500
+ const isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen : internalIsOpen;
9501
+ const ref = react.useRef(null);
9502
+ const isPendingCloseRef = react.useRef(false);
9503
+ const onCloseRef = react.useRef(onClose);
9504
+ const onOpenRef = react.useRef(onOpen);
9505
+ const onOpenChangeRef = react.useRef(onOpenChange);
9506
+ const onBeforeCloseRef = react.useRef(onBeforeClose);
9507
+ const onStateChangeRef = react.useRef(onStateChange);
9508
+ const onGeometryChangeRef = react.useRef(onGeometryChangeProp);
9509
+ react.useLayoutEffect(() => {
9510
+ onCloseRef.current = onClose;
9511
+ onOpenRef.current = onOpen;
9512
+ onOpenChangeRef.current = onOpenChange;
9513
+ onBeforeCloseRef.current = onBeforeClose;
9514
+ onStateChangeRef.current = onStateChange;
9515
+ onGeometryChangeRef.current = onGeometryChangeProp;
9516
+ });
9517
+ const { state, snap, onGeometryChange: snapOnGeometryChange, onSnap, onClickHandle, } = useSheetSnap({ defaultSize, isOpen });
9518
+ react.useEffect(() => {
9519
+ onStateChangeRef.current?.(state);
9520
+ }, [state]);
9521
+ const onGeometryChange = react.useCallback((geometry, dockingEnabled) => {
9522
+ snapOnGeometryChange(geometry, dockingEnabled);
9523
+ onGeometryChangeRef.current?.(geometry);
9524
+ }, [snapOnGeometryChange]);
9525
+ const handleClose = react.useCallback((event, reason) => {
9526
+ setIsOpen(false);
9527
+ onCloseRef.current?.(event, reason);
9528
+ onOpenChangeRef.current?.(false, event, reason);
9529
+ }, []);
9530
+ const requestClose = react.useCallback((event, reason) => {
9531
+ if (onBeforeCloseRef.current) {
9532
+ if (isPendingCloseRef.current) {
9533
+ return;
9534
+ }
9535
+ isPendingCloseRef.current = true;
9536
+ void Promise.resolve(onBeforeCloseRef.current(event, reason))
9537
+ .then(shouldClose => {
9538
+ if (shouldClose) {
9539
+ handleClose(event, reason);
9540
+ }
9541
+ })
9542
+ .finally(() => {
9543
+ isPendingCloseRef.current = false;
9544
+ });
9545
+ return;
9546
+ }
9547
+ handleClose(event, reason);
9548
+ }, [handleClose]);
9549
+ const close = react.useCallback(() => requestClose(undefined, "programmatic"), [requestClose]);
9550
+ const open = react.useCallback(() => {
9551
+ onOpenRef.current?.();
9552
+ onOpenChangeRef.current?.(true);
9553
+ setIsOpen(true);
9554
+ }, []);
9555
+ const { context: floatingContext, refs: floatingRefs } = react$1.useFloating({
9556
+ open: isOpen,
9557
+ onOpenChange: (newIsOpen, event, reason) => {
9558
+ if (newIsOpen)
9559
+ return;
9560
+ const closeReason = reason === "escape-key" || reason === "outside-press" ? reason : undefined;
9561
+ requestClose(event, closeReason);
9562
+ },
9563
+ });
9564
+ const dismissInteraction = react$1.useDismiss(floatingContext, {
9565
+ escapeKey: dismiss.escapeKey,
9566
+ outsidePress: variant === "modal" && dismiss.outsidePress,
9567
+ });
9568
+ const { getFloatingProps } = react$1.useInteractions([dismissInteraction]);
9569
+ const floatingUi = react.useMemo(() => ({ context: floatingContext, refs: floatingRefs, getFloatingProps }), [floatingContext, floatingRefs, getFloatingProps]);
9570
+ const onCloseGesture = react.useMemo(() => (dismiss.gesture ? () => requestClose(undefined, "gesture") : undefined), [dismiss.gesture, requestClose]);
9571
+ return react.useMemo(() => ({
9572
+ isOpen,
9573
+ ref,
9574
+ state,
9575
+ variant,
9576
+ open,
9577
+ close,
9578
+ snap,
9579
+ floatingUi,
9580
+ onGeometryChange,
9581
+ onSnap,
9582
+ onClickHandle,
9583
+ onCloseGesture,
9584
+ }), [isOpen, state, variant, open, close, snap, floatingUi, onGeometryChange, onSnap, onClickHandle, onCloseGesture]);
9585
+ };
9586
+
7549
9587
  const cvaSidebar = cssClassVarianceUtilities.cvaMerge(["apply", "grid", "grid-cols-fr-min", "items-center"]);
7550
9588
  const cvaSidebarChildContainer = cssClassVarianceUtilities.cvaMerge(["apply", "flex", "overflow-hidden", "gap-2", "p-1", "max-w-full"], {
7551
9589
  variants: {
@@ -9350,21 +11388,6 @@ const useElevatedState = (initialState, customState) => {
9350
11388
  return react.useMemo(() => customState ?? [fallbackValue, fallbackSetter], [customState, fallbackValue, fallbackSetter]);
9351
11389
  };
9352
11390
 
9353
- /**
9354
- * Differentiate between the first and subsequent renders.
9355
- *
9356
- * @returns {boolean} Returns true if it is the first render, false otherwise.
9357
- */
9358
- const useIsFirstRender = () => {
9359
- const [isFirstRender, setIsFirstRender] = react.useState(true);
9360
- react.useLayoutEffect(() => {
9361
- queueMicrotask(() => {
9362
- setIsFirstRender(false);
9363
- });
9364
- }, []);
9365
- return isFirstRender;
9366
- };
9367
-
9368
11391
  /**
9369
11392
  * Custom hook for checking if the browser is in fullscreen mode.
9370
11393
  */
@@ -10033,184 +12056,6 @@ const getWindowSize = () => {
10033
12056
  }
10034
12057
  };
10035
12058
 
10036
- /**
10037
- * Blocks scrolling on the document and compensates for scrollbar width to prevent layout shift.
10038
- * Uses scrollbar-gutter: stable to reserve space for the scrollbar when hiding overflow,
10039
- * but only if a scrollbar was actually visible before blocking.
10040
- * This only has an effect with classic scrollbars - overlay scrollbars (macOS default) are unaffected.
10041
- * Returns the original styles so they can be restored later.
10042
- *
10043
- * @returns {OriginalStyles} The original styles before blocking
10044
- */
10045
- const blockDocumentScroll = () => {
10046
- const { body } = document;
10047
- const html = document.documentElement;
10048
- // Check if there's a visible scrollbar before we hide it
10049
- // The scrollbar can be on either <html> or <body> depending on CSS setup
10050
- // (e.g., Storybook sets overflow:hidden on html and overflow:auto on body)
10051
- // Check html scrollbar: window.innerWidth includes scrollbar, clientWidth doesn't
10052
- const htmlScrollbarWidth = window.innerWidth - html.clientWidth;
10053
- // Check body scrollbar: offsetWidth includes border+scrollbar, clientWidth excludes both
10054
- const bodyStyle = window.getComputedStyle(body);
10055
- const bodyBorderLeft = parseInt(bodyStyle.borderLeftWidth) || 0;
10056
- const bodyBorderRight = parseInt(bodyStyle.borderRightWidth) || 0;
10057
- const bodyScrollbarWidth = body.offsetWidth - bodyBorderLeft - bodyBorderRight - body.clientWidth;
10058
- // Use whichever scrollbar is present
10059
- const hasVisibleScrollbar = htmlScrollbarWidth > 0 || bodyScrollbarWidth > 0;
10060
- // Store original values before modifying
10061
- const originalStyles = {
10062
- html: {
10063
- position: html.style.position,
10064
- overflow: html.style.overflow,
10065
- },
10066
- body: {
10067
- position: body.style.position,
10068
- overflow: body.style.overflow,
10069
- scrollbarGutter: body.style.scrollbarGutter,
10070
- },
10071
- };
10072
- // Block scroll on both html and body for cross-browser compatibility
10073
- html.style.position = "relative";
10074
- html.style.overflow = "hidden";
10075
- body.style.position = "relative";
10076
- body.style.overflow = "hidden";
10077
- // Apply scrollbar-gutter on body (where the scrollbar typically lives when content scrolls)
10078
- // This reserves space for the scrollbar even when overflow is hidden, preventing layout shift.
10079
- // Only has effect with classic scrollbars - overlay scrollbars (macOS default) are unaffected.
10080
- if (hasVisibleScrollbar) {
10081
- body.style.scrollbarGutter = "stable";
10082
- }
10083
- return originalStyles;
10084
- };
10085
- /**
10086
- * Restores document scrolling by restoring the provided original styles.
10087
- *
10088
- * @param originalStyles - The original styles to restore
10089
- */
10090
- const restoreDocumentScroll = (originalStyles) => {
10091
- const { body } = document;
10092
- const html = document.documentElement;
10093
- // Restore original values instead of just clearing
10094
- if (originalStyles.html) {
10095
- html.style.position = originalStyles.html.position;
10096
- html.style.overflow = originalStyles.html.overflow;
10097
- }
10098
- if (originalStyles.body) {
10099
- body.style.position = originalStyles.body.position;
10100
- body.style.overflow = originalStyles.body.overflow;
10101
- body.style.scrollbarGutter = originalStyles.body.scrollbarGutter;
10102
- }
10103
- };
10104
- /**
10105
- * Blocks scrolling on a custom container element.
10106
- * Uses scrollbar-gutter: stable to reserve space for the scrollbar when hiding overflow,
10107
- * but only if a scrollbar was actually visible before blocking.
10108
- * This only has an effect with classic scrollbars - overlay scrollbars (macOS default) are unaffected.
10109
- * Returns the original styles so they can be restored later.
10110
- *
10111
- * @param container - The container element to block scroll on
10112
- * @returns {OriginalStyles} The original styles before blocking
10113
- */
10114
- const blockContainerScroll = (container) => {
10115
- // Check if there's a visible scrollbar before we hide it
10116
- // offsetWidth includes border + scrollbar, clientWidth excludes both
10117
- // We need to subtract borders to isolate the scrollbar width
10118
- const style = window.getComputedStyle(container);
10119
- const borderLeft = parseInt(style.borderLeftWidth) || 0;
10120
- const borderRight = parseInt(style.borderRightWidth) || 0;
10121
- const scrollbarWidth = container.offsetWidth - borderLeft - borderRight - container.clientWidth;
10122
- const hasVisibleScrollbar = scrollbarWidth > 0;
10123
- const originalStyles = {
10124
- container: {
10125
- overflow: container.style.overflow,
10126
- scrollbarGutter: container.style.scrollbarGutter,
10127
- },
10128
- };
10129
- container.style.overflow = "hidden";
10130
- // Only add scrollbar-gutter if there was a visible scrollbar to preserve space for
10131
- // This prevents adding unnecessary space when content doesn't overflow
10132
- if (hasVisibleScrollbar) {
10133
- container.style.scrollbarGutter = "stable";
10134
- }
10135
- return originalStyles;
10136
- };
10137
- /**
10138
- * Restores container scrolling by restoring the provided original styles.
10139
- *
10140
- * @param container - The container element to restore scroll on
10141
- * @param originalStyles - The original styles to restore
10142
- */
10143
- const restoreContainerScroll = (container, originalStyles) => {
10144
- if (originalStyles.container) {
10145
- container.style.overflow = originalStyles.container.overflow;
10146
- container.style.scrollbarGutter = originalStyles.container.scrollbarGutter;
10147
- }
10148
- };
10149
- /**
10150
- * Hook that provides scroll blocking functionality.
10151
- * This properly accounts for existing body padding to prevent layout shifts.
10152
- *
10153
- * Each instance gets its own stored original styles via refs, preventing
10154
- * conflicts when multiple components are used on the same page. The hook also ensures
10155
- * cleanup on unmount if scroll is still blocked.
10156
- *
10157
- * @param scrollContainer - The DOM element whose scroll should be blocked. Defaults to document.body.
10158
- * @returns {{blockScroll: () => void, restoreScroll: () => void}} Object containing blockScroll and restoreScroll functions
10159
- */
10160
- const useScrollBlock = (scrollContainer = typeof document !== "undefined" ? document.body : null // default to document.body if no scroll container is provided
10161
- ) => {
10162
- const originalStylesRef = react.useRef(null);
10163
- const isBlockedRef = react.useRef(false);
10164
- /**
10165
- * Blocks scrolling and stores original styles for restoration.
10166
- * Blocks the document (body/html) if scroll container is the document body,
10167
- * or blocks the custom container if a custom container is provided.
10168
- */
10169
- const blockScroll = react.useCallback(() => {
10170
- if (isBlockedRef.current || !scrollContainer) {
10171
- return; // Already blocked or no scroll container
10172
- }
10173
- if (scrollContainer === document.body) {
10174
- originalStylesRef.current = blockDocumentScroll();
10175
- }
10176
- else {
10177
- originalStylesRef.current = blockContainerScroll(scrollContainer);
10178
- }
10179
- isBlockedRef.current = true;
10180
- }, [scrollContainer]);
10181
- /**
10182
- * Restores scrolling using the previously stored original styles.
10183
- */
10184
- const restoreScroll = react.useCallback(() => {
10185
- if (!isBlockedRef.current || !scrollContainer || !originalStylesRef.current) {
10186
- return;
10187
- }
10188
- if (scrollContainer === document.body) {
10189
- restoreDocumentScroll(originalStylesRef.current);
10190
- }
10191
- else {
10192
- restoreContainerScroll(scrollContainer, originalStylesRef.current);
10193
- }
10194
- originalStylesRef.current = null;
10195
- isBlockedRef.current = false;
10196
- }, [scrollContainer]);
10197
- // Cleanup: restore scroll if component unmounts while scroll is blocked
10198
- react.useEffect(() => {
10199
- return () => {
10200
- if (isBlockedRef.current && scrollContainer && originalStylesRef.current) {
10201
- if (scrollContainer === document.body) {
10202
- restoreDocumentScroll(originalStylesRef.current);
10203
- }
10204
- else {
10205
- restoreContainerScroll(scrollContainer, originalStylesRef.current);
10206
- }
10207
- isBlockedRef.current = false;
10208
- }
10209
- };
10210
- }, [scrollContainer]);
10211
- return react.useMemo(() => ({ blockScroll, restoreScroll }), [blockScroll, restoreScroll]);
10212
- };
10213
-
10214
12059
  /**
10215
12060
  * A useRef that updates its given value whenever it changes.
10216
12061
  *
@@ -10332,8 +12177,12 @@ exports.PreferenceCard = PreferenceCard;
10332
12177
  exports.PreferenceCardSkeleton = PreferenceCardSkeleton;
10333
12178
  exports.Prompt = Prompt;
10334
12179
  exports.ROLE_CARD = ROLE_CARD;
12180
+ exports.SHEET_TRANSITION_DURATION = SHEET_TRANSITION_DURATION;
12181
+ exports.SHEET_TRANSITION_DURATION_MS = SHEET_TRANSITION_DURATION_MS;
12182
+ exports.SHEET_TRANSITION_EASING = SHEET_TRANSITION_EASING;
10335
12183
  exports.SectionHeader = SectionHeader;
10336
12184
  exports.SegmentedValueBar = SegmentedValueBar;
12185
+ exports.Sheet = Sheet;
10337
12186
  exports.Sidebar = Sidebar;
10338
12187
  exports.SkeletonBlock = SkeletonBlock;
10339
12188
  exports.SkeletonLabel = SkeletonLabel;
@@ -10444,6 +12293,8 @@ exports.useScrollDetection = useScrollDetection;
10444
12293
  exports.useSelfUpdatingRef = useSelfUpdatingRef;
10445
12294
  exports.useSessionStorage = useSessionStorage;
10446
12295
  exports.useSessionStorageReducer = useSessionStorageReducer;
12296
+ exports.useSheet = useSheet;
12297
+ exports.useSheetSnap = useSheetSnap;
10447
12298
  exports.useTextSearch = useTextSearch;
10448
12299
  exports.useTimeout = useTimeout;
10449
12300
  exports.useViewportBreakpoints = useViewportBreakpoints;