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