@trackunit/react-components 1.21.14 → 1.21.17

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
@@ -10,7 +10,7 @@ import IconSpriteSolid from '@trackunit/ui-icons/icons-sprite-solid.svg';
10
10
  import { snakeCase, titleCase } from 'string-ts';
11
11
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
12
12
  import { Slot, Slottable } from '@radix-ui/react-slot';
13
- import { Link, useBlocker, useSearch, useNavigate } from '@tanstack/react-router';
13
+ import { Link, useBlocker, useNavigate, useLocation, useSearch } from '@tanstack/react-router';
14
14
  import { isEqual, omit } from 'es-toolkit';
15
15
  import { useFloating, offset, flip, shift, size, autoUpdate, useClick, useDismiss, useHover as useHover$1, safePolygon, useRole, useInteractions, FloatingPortal, useMergeRefs as useMergeRefs$1, FloatingFocusManager, arrow, useTransitionStatus, FloatingArrow } from '@floating-ui/react';
16
16
  import { twMerge } from 'tailwind-merge';
@@ -18,8 +18,9 @@ import { useVirtualizer } from '@tanstack/react-virtual';
18
18
  import { HelmetProvider, Helmet } from 'react-helmet-async';
19
19
  import { Trigger, Content as Content$1, List as List$1, Root } from '@radix-ui/react-tabs';
20
20
  import { gzipSync, gunzipSync } from 'fflate';
21
- import { z } from 'zod';
22
21
  import superjson from 'superjson';
22
+ import { z } from 'zod';
23
+ import { dequal } from 'dequal';
23
24
 
24
25
  const cvaIcon = cvaMerge(["aspect-square", "inline-grid", "relative", "shrink-0"], {
25
26
  variants: {
@@ -7544,245 +7545,2284 @@ const SectionHeader = ({ title, subtitle, "data-testid": dataTestId, addons, ref
7544
7545
  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
7546
  };
7546
7547
 
7547
- const cvaSidebar = cvaMerge(["apply", "grid", "grid-cols-fr-min", "items-center"]);
7548
- const cvaSidebarChildContainer = cvaMerge(["apply", "flex", "overflow-hidden", "gap-2", "p-1", "max-w-full"], {
7549
- variants: {
7550
- breakpoint: {
7551
- xs: ["xs:overflow-visible", "xs:p-0", "xs:grid", "xs:grid-cols-1", "xs:w-full"],
7552
- sm: ["sm:overflow-visible", "sm:p-0", "sm:grid", "sm:grid-cols-1", "sm:w-full"],
7553
- md: ["md:overflow-visible", "md:p-0", "md:grid", "md:grid-cols-1", "md:w-full"],
7554
- lg: ["lg:overflow-visible", "lg:p-0", "lg:grid", "lg:grid-cols-1", "lg:w-full"],
7555
- xl: ["xl:overflow-visible", "xl:p-0", "xl:grid", "xl:grid-cols-1", "xl:w-full"],
7556
- "2xl": ["2xl:overflow-visible", "2xl:p-0", "2xl:grid", "2xl:grid-cols-1", "2xl:w-full"],
7557
- "3xl": ["3xl:overflow-visible", "3xl:p-0", "3xl:grid", "3xl:grid-cols-1", "3xl:w-full"],
7558
- },
7559
- },
7560
- });
7561
-
7562
7548
  /**
7563
- * A hook used to detect what children are overflowing a container.
7549
+ * Differentiate between the first and subsequent renders.
7564
7550
  *
7565
- * @param {OverflowItemsOptions} root0 The options for the hook.
7566
- * @param {number | Array<number>} root0.threshold The threshold for the intersection observer.
7567
- * @returns {object} The overflow items and the ref to the container.
7551
+ * @returns {boolean} Returns true if it is the first render, false otherwise.
7568
7552
  */
7569
- const useOverflowItems = ({ threshold = 1, childUniqueIdentifierAttribute = "id", children, }) => {
7570
- const [itemOverflowMap, setItemOverflowMap] = useState({});
7571
- const overflowContainerRef = useRef(null);
7572
- const observerRef = useRef(null);
7573
- const handleIntersection = useCallback(entries => {
7574
- const updatedEntries = {};
7575
- entries.forEach(entry => {
7576
- // @ts-expect-error - suppressImplicitAnyIndexErrors
7577
- const targetElementId = entry.target[childUniqueIdentifierAttribute];
7578
- if (targetElementId !== null && targetElementId !== undefined && targetElementId !== "") {
7579
- updatedEntries[targetElementId] = entry.isIntersecting ? false : true;
7580
- }
7553
+ const useIsFirstRender = () => {
7554
+ const [isFirstRender, setIsFirstRender] = useState(true);
7555
+ useLayoutEffect(() => {
7556
+ queueMicrotask(() => {
7557
+ setIsFirstRender(false);
7581
7558
  });
7582
- setItemOverflowMap(prev => ({ ...prev, ...updatedEntries }));
7583
- }, [childUniqueIdentifierAttribute]);
7584
- const observe = useCallback(() => {
7585
- const node = overflowContainerRef.current;
7586
- if (node) {
7587
- const root = overflowContainerRef.current;
7588
- const options = { root, threshold };
7589
- const observer = new IntersectionObserver(handleIntersection, options);
7590
- Array.from(node.children).forEach(child => {
7591
- observer.observe(child);
7592
- });
7593
- observer.observe(node);
7594
- observerRef.current = observer;
7595
- }
7596
- }, [handleIntersection, threshold]);
7597
- const unobserve = useCallback(() => {
7598
- const currentObserver = observerRef.current;
7599
- currentObserver?.disconnect();
7600
- observerRef.current = null;
7601
7559
  }, []);
7602
- const initializeObserver = useCallback(() => {
7603
- unobserve();
7604
- observe();
7605
- }, [observe, unobserve]);
7606
- useEffect(() => {
7607
- initializeObserver();
7608
- return () => {
7609
- unobserve();
7610
- };
7611
- }, [initializeObserver, unobserve, children]);
7612
- return useMemo(() => ({ overflowContainerRef, itemOverflowMap }), [overflowContainerRef, itemOverflowMap]);
7560
+ return isFirstRender;
7613
7561
  };
7614
7562
 
7615
7563
  /**
7616
- * Sidebar renders a responsive horizontal/vertical navigation bar that automatically collapses overflowing items into a MoreMenu.
7617
- * It is rendered horizontally until a given breakpoint, then stacks vertically.
7618
- *
7619
- * **Important:** The Sidebar is just a layout wrapper. You are responsible for styling the children.
7620
- * For the overflow functionality, use `min-w-[*]` or `flex-shrink-0` on child elements.
7564
+ * Width constraint for anchored sheets (start/end positioning).
7565
+ * Uses container-relative units with a pixel cap.
7566
+ */
7567
+ /**
7568
+ * Transition timing for snap animations gentle deceleration with minimal overshoot.
7569
+ */
7570
+ const SHEET_TRANSITION_DURATION_MS = 300;
7571
+ const SHEET_TRANSITION_DURATION = `${SHEET_TRANSITION_DURATION_MS}ms`;
7572
+ const SHEET_TRANSITION_EASING = "cubic-bezier(0.2, 0.0, 0.0, 1.0)";
7573
+ /**
7574
+ * Threshold (as a fraction of container height) below the smallest snap
7575
+ * at which the sheet will close on release.
7576
+ */
7577
+ const CLOSE_THRESHOLD_FRACTION = 0.15;
7578
+ /**
7579
+ * Dead zone in pixels below the smallest snap before the dismiss
7580
+ * visual indicator starts showing. Prevents the cross from appearing
7581
+ * too early when the user barely drags past the lowest snap.
7582
+ */
7583
+ const DISMISS_DEAD_ZONE_PX = 20;
7584
+ /**
7585
+ * Pixel range within which the snap-proximity visual feedback activates.
7586
+ * The handle widens and darkens as the sheet approaches a snap point.
7587
+ */
7588
+ const SNAP_PROXIMITY_RANGE_PX = 50;
7589
+ /** Fallback pixel height for the docked state. */
7590
+ const DOCKED_HEIGHT_PX = 64;
7591
+ /**
7592
+ * Top margin subtracted from the largest snap level so the sheet never
7593
+ * fully covers the container (leaves a small gap at the top).
7594
+ */
7595
+ const FULL_HEIGHT_TOP_MARGIN_PX = 40;
7596
+ /** Minimum pixel spacing between adjacent snap points (and between dock and first level). */
7597
+ const MIN_SNAP_SPACING_PX = 120;
7598
+ /** Maximum number of snap levels regardless of available height. */
7599
+ const MAX_SNAP_LEVELS = 5;
7600
+ /** Minimum number of snap levels when there is enough space. */
7601
+ const MIN_SNAP_LEVELS = 2;
7602
+ /**
7603
+ * Determines the number of snap levels based on container height.
7621
7604
  *
7622
- * When testing, add `setupIntersectionObserver();` to your `jest.setup.ts` file.
7605
+ * When docking is enabled the dock height is treated as an anchor
7606
+ * and only the space above it is divided into adaptive levels, ensuring
7607
+ * the minimum spacing guarantee applies between dock and first snap too.
7608
+ */
7609
+ const getAdaptiveLevelCount = (containerHeight, dockingEnabled, dockedHeightPx = DOCKED_HEIGHT_PX) => {
7610
+ const maxHeight = Math.max(0, containerHeight - FULL_HEIGHT_TOP_MARGIN_PX);
7611
+ if (maxHeight <= 0)
7612
+ return 0;
7613
+ const availableSpace = dockingEnabled ? maxHeight - dockedHeightPx : maxHeight;
7614
+ if (availableSpace <= 0)
7615
+ return 0;
7616
+ const bySpacing = Math.floor(availableSpace / MIN_SNAP_SPACING_PX);
7617
+ return Math.max(MIN_SNAP_LEVELS, Math.min(MAX_SNAP_LEVELS, bySpacing));
7618
+ };
7619
+ /**
7620
+ * Computes an array of snap level heights in pixels, evenly distributed
7621
+ * across the available space. When docking is enabled the distribution
7622
+ * starts from the docked height rather than 0, so the first level is
7623
+ * always at least `MIN_SNAP_SPACING_PX` above the dock.
7623
7624
  *
7624
- * ### When to use
7625
- * Use Sidebar for secondary in-page navigation (e.g., switching between views within a page). Works well with `Tabs` for page-level navigation.
7625
+ * The resulting heights are sorted ascending (smallest first).
7626
7626
  *
7627
- * ### When not to use
7628
- * Do not use Sidebar for primary app navigation (the main menu). For top-level navigation, use the app shell navigation.
7627
+ * @param containerHeight - Container height in pixels.
7628
+ * @param dockingEnabled - Whether docked mode is available.
7629
+ * @param dockedHeightPx - Pixel height of the docked state (measured from content).
7630
+ * @returns {ReadonlyArray<number>} Array of pixel heights for each snap level.
7631
+ */
7632
+ const computeSnapLevelHeights = (containerHeight, dockingEnabled, dockedHeightPx = DOCKED_HEIGHT_PX) => {
7633
+ const levelCount = getAdaptiveLevelCount(containerHeight, dockingEnabled, dockedHeightPx);
7634
+ const maxHeight = Math.max(0, containerHeight - FULL_HEIGHT_TOP_MARGIN_PX);
7635
+ const base = dockingEnabled ? dockedHeightPx : 0;
7636
+ const range = maxHeight - base;
7637
+ if (levelCount <= 0 || range <= 0) {
7638
+ return maxHeight > 0 ? [maxHeight] : [];
7639
+ }
7640
+ const levels = [];
7641
+ for (let i = 0; i < levelCount; i++) {
7642
+ const fraction = (i + 1) / levelCount;
7643
+ levels.push(Math.round(base + fraction * range));
7644
+ }
7645
+ return levels;
7646
+ };
7647
+ /**
7648
+ * Converts a snap level index to a CSS height string using container-relative units.
7629
7649
  *
7630
- * @example Responsive sidebar with navigation items
7631
- * ```tsx
7632
- * import { Sidebar, Button } from "@trackunit/react-components";
7650
+ * When docking is enabled the height range starts from the docked height
7651
+ * so the CSS value reflects the shifted distribution.
7633
7652
  *
7634
- * const NavigationSidebar = () => (
7635
- * <Sidebar breakpoint="lg">
7636
- * <Button id="overview" variant="ghost-neutral" className="min-w-[100px]">
7637
- * Overview
7638
- * </Button>
7639
- * <Button id="assets" variant="ghost-neutral" className="min-w-[100px]">
7640
- * Assets
7641
- * </Button>
7642
- * <Button id="reports" variant="ghost-neutral" className="min-w-[100px]">
7643
- * Reports
7644
- * </Button>
7645
- * <Button id="settings" variant="ghost-neutral" className="min-w-[100px]">
7646
- * Settings
7647
- * </Button>
7648
- * </Sidebar>
7649
- * );
7650
- * ```
7651
- * @param {SidebarProps} props - The props for the Sidebar component
7652
- * @returns {ReactElement} Sidebar component
7653
+ * @param levelIndex - Zero-based snap level index.
7654
+ * @param totalLevels - Total number of snap levels.
7655
+ * @param dockingEnabled - Whether docked mode is available.
7656
+ * @param dockedHeightPx - Pixel height of the docked state (measured from content).
7653
7657
  */
7654
- const Sidebar = ({ childContainerClassName, children, breakpoint = "lg", className, "data-testid": dataTestId = "sidebar", moreMenuProps, menuListProps, overflowThreshold, ref, }) => {
7655
- const { overflowContainerRef, itemOverflowMap } = useOverflowItems({
7656
- children,
7657
- childUniqueIdentifierAttribute: "id",
7658
- threshold: overflowThreshold ?? 0.8,
7659
- });
7660
- const overflowItemCount = objectValues(itemOverflowMap).filter(isOverflowing => isOverflowing).length;
7661
- const itemVisibilityClassName = (id) => {
7662
- if (itemOverflowMap[id] === true) {
7663
- return "invisible";
7664
- }
7665
- return "visible";
7666
- };
7667
- return (jsxs("div", { className: cvaSidebar({ className }), "data-testid": dataTestId, ref: ref, children: [jsx("div", { className: cvaSidebarChildContainer({ breakpoint, className: childContainerClassName }), "data-testid": `${dataTestId}-child-container`, ref: overflowContainerRef, children: Children.map(children, child => {
7668
- return cloneElement(child, {
7669
- className: twMerge(child.props.className, itemVisibilityClassName(child.props.id)),
7670
- });
7671
- }) }), overflowItemCount > 0 ? (jsx(MoreMenu, { iconButtonProps: {
7672
- variant: "ghost-neutral",
7673
- }, ...moreMenuProps, className: moreMenuProps?.className, "data-testid": `${dataTestId}-more-menu`, children: close => (jsx(MenuList, { ...menuListProps, "data-testid": dataTestId, children: Children.map(children, child => {
7674
- return itemOverflowMap[child.props.id] === true
7675
- ? cloneElement(child, {
7676
- onClick: e => {
7677
- child.props.onClick?.(e);
7678
- close();
7679
- },
7680
- className: "w-full",
7681
- })
7682
- : null;
7683
- }) })) })) : null] }));
7658
+ const getSnapLevelCssHeight = (levelIndex, totalLevels, dockingEnabled, dockedHeightPx = DOCKED_HEIGHT_PX) => {
7659
+ if (totalLevels <= 0)
7660
+ return "0px";
7661
+ const fraction = (levelIndex + 1) / totalLevels;
7662
+ if (fraction >= 1) {
7663
+ return `calc(100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px)`;
7664
+ }
7665
+ if (dockingEnabled) {
7666
+ return `calc(${dockedHeightPx}px + ${fraction} * (100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX + dockedHeightPx}px))`;
7667
+ }
7668
+ return `calc(${fraction} * (100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px))`;
7684
7669
  };
7685
7670
 
7686
- const cvaTabsRoot = cvaMerge([]);
7687
- const cvaTabList = cvaMerge([
7688
- "flex",
7689
- "flex-row",
7690
- "border-b",
7691
- "border-b-neutral-200",
7692
- "overflow-auto",
7693
- "no-scrollbar",
7694
- ]);
7695
- const cvaTabContent = cvaMerge([]);
7696
- const cvaTab = cvaMerge([
7697
- "flex",
7698
- "items-center",
7699
- "justify-center",
7700
- "gap-2",
7701
- "text-sm",
7702
- "px-6",
7703
- "py-2",
7704
- "cursor-pointer",
7705
- "transition",
7706
- "duration-200",
7707
- "ease-in-out",
7708
- "whitespace-nowrap",
7709
- "border-b-2",
7710
- "border-b-transparent",
7711
- "hover:border-b-transparent",
7712
- "data-[state=active]:border-b-primary-600",
7713
- "data-[state=active]:font-semibold",
7714
- "data-[state=active]:text-neutral-900",
7715
- "data-[state=inactive]:font-medium",
7716
- "data-[state=inactive]:text-neutral-700",
7717
- "data-[state=inactive]:hover:text-neutral-900",
7718
- "disabled:cursor-not-allowed",
7719
- "data-[state=active]disabled:text-neutral-400",
7720
- "data-[state=inactive]:disabled:text-neutral-400",
7721
- ], {
7671
+ /**
7672
+ * Container wrapper that creates the positioning context and container query
7673
+ * context for the Sheet. Sets `container-type: size` so cqh and cqw units
7674
+ * resolve against this wrapper's dimensions.
7675
+ *
7676
+ * Always absolutely positioned within the provided container element.
7677
+ */
7678
+ const cvaSheetContainer = cvaMerge(["absolute", "inset-0", "z-overlay", "overflow-clip", "[container-type:size]"], {
7722
7679
  variants: {
7723
- isFullWidth: {
7724
- true: "w-full",
7725
- false: "",
7680
+ docked: {
7681
+ true: ["pointer-events-none"],
7682
+ false: [],
7726
7683
  },
7727
7684
  },
7728
7685
  defaultVariants: {
7729
- isFullWidth: false,
7686
+ docked: false,
7730
7687
  },
7731
7688
  });
7732
-
7733
7689
  /**
7734
- * Tab is an individual tab trigger within a TabList.
7735
- * Each Tab requires a unique `value` prop that corresponds to a TabContent with the same value.
7736
- * Supports optional icons, suffixes (e.g., badges), and rendering as a custom child element via `asChild`.
7737
- *
7738
- * ### When to use
7739
- * Use Tab inside a `TabList` to create clickable tab triggers. Each Tab maps to a `TabContent` panel.
7690
+ * Sheet panel the main content surface, always positioned at the bottom
7691
+ * of the container.
7740
7692
  *
7741
- * ### When not to use
7742
- * Do not use Tab outside of a `TabList`/`Tabs` context. For standalone buttons, use `Button`.
7693
+ * Height and vertical positioning are controlled via inline styles returned
7694
+ * by `getSheetPanelStyle()`:
7743
7695
  *
7744
- * @example Tab with icon and badge suffix
7745
- * ```tsx
7746
- * import { Tabs, TabList, Tab, TabContent, Badge } from "@trackunit/react-components";
7696
+ * - `height`: Target height for the current snap (e.g., "50cqh"), or
7697
+ * `--sheet-drag-height` during drag so the sheet stays anchored at the bottom
7698
+ * - `transform`: translateY for entry slide-up animation
7699
+ * - `transition`: separate curves for entry (transform), close (height collapse),
7700
+ * and snap changes (height)
7747
7701
  *
7748
- * const TabsWithBadge = () => (
7749
- * <Tabs defaultValue="alerts">
7750
- * <TabList>
7751
- * <Tab value="alerts" iconName="Bell" suffix={<Badge count={3} color="danger" />}>
7752
- * Alerts
7753
- * </Tab>
7754
- * <Tab value="history" iconName="Clock">History</Tab>
7755
- * </TabList>
7756
- * <TabContent value="alerts">Active alerts</TabContent>
7757
- * <TabContent value="history">Event history</TabContent>
7758
- * </Tabs>
7759
- * );
7760
- * ```
7761
- * @param {TabProps} props - The props for the Tab component
7762
- * @returns {ReactElement} Tab component
7702
+ * During drag, the gesture hook sets `--sheet-drag-height` and `transition: none`
7703
+ * directly on the element for immediate visual feedback, then restores on release.
7763
7704
  */
7764
- const Tab = ({ value, isFullWidth = false, iconName = undefined, "data-testid": dataTestId, className, children, suffix, asChild = false, appendTabStylesToChildIfAsChild = true, ref, ...rest }) => {
7765
- const renderContent = () => (jsxs(Fragment, { children: [iconName !== undefined ? jsx(Icon, { name: iconName, size: "small" }) : null, isValidElement(children) ? children.props.children : children, suffix] }));
7766
- const commonProps = {
7767
- className: appendTabStylesToChildIfAsChild ? cvaTab({ className, isFullWidth }) : className,
7768
- ...rest,
7769
- };
7770
- return (jsx(Trigger, { asChild: true, ref: ref, value: value, children: asChild && typeof children !== "string" ? (cloneElement(children, {
7771
- ...commonProps,
7772
- children: renderContent(),
7773
- })) : (jsx("button", { ...commonProps, "data-testid": dataTestId, children: renderContent() })) }));
7774
- };
7775
-
7705
+ const cvaSheetPanel = cvaMerge(["absolute", "bottom-0", "flex", "flex-col", "pointer-events-auto", "rounded-b-none", "rounded-t-xl", "z-overlay"], {
7706
+ variants: {
7707
+ anchor: {
7708
+ center: ["inset-x-0", "mx-auto", "max-w-3xl"],
7709
+ start: ["left-[12px]", "max-w-[min(90cqw,420px)]"],
7710
+ end: ["right-[12px]", "max-w-[min(90cqw,420px)]"],
7711
+ },
7712
+ variant: {
7713
+ default: [],
7714
+ modal: [],
7715
+ floating: ["rounded-b-xl", "bottom-[12px]"],
7716
+ },
7717
+ },
7718
+ defaultVariants: {
7719
+ anchor: "center",
7720
+ variant: "default",
7721
+ },
7722
+ });
7776
7723
  /**
7777
- * TabContent is the content panel displayed when its corresponding Tab is active.
7778
- * Each TabContent must have a `value` prop that matches the `value` of its corresponding Tab trigger.
7779
- * TabContent is only rendered when its tab is selected (unless `forceMount` is set).
7780
- *
7781
- * ### When to use
7782
- * Use TabContent inside a `Tabs` component, one for each `Tab` trigger.
7783
- *
7784
- * ### When not to use
7785
- * Do not use TabContent outside of a `Tabs` context. For conditionally shown content, use standard conditional rendering.
7724
+ * Scrollable area below the drag handle. Nested inside the Sheet panel (Card)
7725
+ * so the Card's base `overflow-clip` clips this element's scrollbar at the
7726
+ * rounded corners. The handle stays fixed above, outside the scroll context.
7727
+ *
7728
+ * `fillHeight` controls whether the scroll area stretches to fill available
7729
+ * panel space. Enabled for level/docked modes (fixed panel height); disabled
7730
+ * for fit mode so that `scrollHeight` reflects natural content height rather
7731
+ * than the stretched layout — critical for correct fit-height measurement.
7732
+ */
7733
+ const cvaSheetScrollArea = cvaMerge(["flex", "flex-col", "min-h-0", "overflow-x-hidden", "overflow-y-auto"], {
7734
+ variants: {
7735
+ fillHeight: {
7736
+ true: ["flex-grow"],
7737
+ false: [],
7738
+ },
7739
+ },
7740
+ defaultVariants: {
7741
+ fillHeight: true,
7742
+ },
7743
+ });
7744
+ /**
7745
+ * CSS transition shorthand for the sheet panel during normal (open) state.
7746
+ * Animates both `transform` (entry slide-up) and `height` (snap changes).
7747
+ */
7748
+ const SHEET_OPEN_TRANSITION = `transform ${SHEET_TRANSITION_DURATION} ${SHEET_TRANSITION_EASING}, height ${SHEET_TRANSITION_DURATION} ${SHEET_TRANSITION_EASING}`;
7749
+ /**
7750
+ * Transform-only transition for auto-height mode during entry. Height is
7751
+ * excluded because modern browsers (Chrome 129+) can transition `fit-content`
7752
+ * via `interpolate-size: allow-keywords`, which causes unwanted height
7753
+ * animation as content mounts during the slide-up.
7754
+ */
7755
+ const SHEET_TRANSFORM_TRANSITION = `transform ${SHEET_TRANSITION_DURATION} ${SHEET_TRANSITION_EASING}`;
7756
+ /**
7757
+ * Height-only transition for the collapse phase of the close animation.
7758
+ */
7759
+ const SHEET_CLOSE_TRANSITION = `height ${SHEET_TRANSITION_DURATION} ${SHEET_TRANSITION_EASING}`;
7760
+ // ---------------------------------------------------------------------------
7761
+ // Close animation strategies (per variant)
7762
+ // ---------------------------------------------------------------------------
7763
+ /** Height the floating variant collapses to before the vanish phase. */
7764
+ const FLOATING_COLLAPSE_HEIGHT_PX = 20;
7765
+ /** Snappy transition for the floating vanish phase (scale + fade). */
7766
+ const FLOATING_VANISH_TRANSITION = `opacity 120ms ease-out, transform 120ms ease-out`;
7767
+ /**
7768
+ * default / modal — single-phase collapse to zero height.
7769
+ * Unmounts when height reaches 0.
7770
+ */
7771
+ const getDefaultCloseStyle = () => ({
7772
+ height: "0",
7773
+ overflow: "hidden",
7774
+ transition: SHEET_CLOSE_TRANSITION,
7775
+ });
7776
+ /**
7777
+ * floating — two-phase close:
7778
+ * 1. "collapsing" shrinks height to a small pill.
7779
+ * 2. "vanishing" scales down and fades out the remaining pill.
7780
+ */
7781
+ const getFloatingCloseStyle = (phase) => {
7782
+ switch (phase) {
7783
+ case "collapsing":
7784
+ return {
7785
+ height: `${FLOATING_COLLAPSE_HEIGHT_PX}px`,
7786
+ overflow: "hidden",
7787
+ transition: SHEET_CLOSE_TRANSITION,
7788
+ };
7789
+ case "vanishing":
7790
+ return {
7791
+ height: `${FLOATING_COLLAPSE_HEIGHT_PX}px`,
7792
+ opacity: 0,
7793
+ overflow: "hidden",
7794
+ transform: "scale(0.8)",
7795
+ transition: FLOATING_VANISH_TRANSITION,
7796
+ };
7797
+ default:
7798
+ return {};
7799
+ }
7800
+ };
7801
+ /**
7802
+ * Resolves the close-animation CSS for the given variant and phase.
7803
+ */
7804
+ const getCloseStyle = (variant, phase) => {
7805
+ switch (variant) {
7806
+ case "floating":
7807
+ return getFloatingCloseStyle(phase);
7808
+ case "default":
7809
+ case "modal":
7810
+ return getDefaultCloseStyle();
7811
+ default:
7812
+ return getDefaultCloseStyle();
7813
+ }
7814
+ };
7815
+ // ---------------------------------------------------------------------------
7816
+ /**
7817
+ * Returns inline styles for the sheet panel element.
7818
+ *
7819
+ * Entry animation uses a transform slide-up (translateY 100% → 0).
7820
+ * Close animation is variant-aware — see `getCloseStyle` for details.
7821
+ * Snap-level changes animate height while the sheet is open.
7822
+ */
7823
+ const getSheetPanelStyle = ({ snapHeight, isOpen, closePhase, variant, autoHeight, maxHeight, isDragging, suppressTransition = false, }) => {
7824
+ if (closePhase !== "idle") {
7825
+ return getCloseStyle(variant, closePhase);
7826
+ }
7827
+ return {
7828
+ height: autoHeight ? "fit-content" : `var(--sheet-drag-height, ${snapHeight})`,
7829
+ maxHeight: maxHeight ?? `calc(100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px)`,
7830
+ pointerEvents: "auto",
7831
+ transform: isOpen
7832
+ ? "translateY(0) scale(var(--sheet-stack-scale, 1))"
7833
+ : "translateY(100%) scale(var(--sheet-stack-scale, 1))",
7834
+ transformOrigin: "bottom center",
7835
+ transition: isDragging
7836
+ ? "none"
7837
+ : suppressTransition || autoHeight || !isOpen
7838
+ ? SHEET_TRANSFORM_TRANSITION
7839
+ : SHEET_OPEN_TRANSITION,
7840
+ };
7841
+ };
7842
+
7843
+ /**
7844
+ * Centralized visual intensity parameters for the sheet handle line.
7845
+ *
7846
+ * All values control the handle bar (line) color, which mixes between
7847
+ * `base` and `emphasized` colors. The mixing factor is the maximum of:
7848
+ * - `--sheet-dismiss-progress` (0-1, set on the panel by gesture hook or Sheet effect)
7849
+ * - `--sheet-snap-proximity` * proximityWeight (approaching a snap point)
7850
+ * - `--handle-interaction` (hover / dragging / pressed states below)
7851
+ *
7852
+ * Interaction values are set via CSS custom property on the handle div
7853
+ * (Tailwind arbitrary properties + pseudo-classes). Keep these in sync
7854
+ * with the CVA class strings in `cvaSheetHandle`.
7855
+ */
7856
+ const HANDLE_VISUALS = {
7857
+ line: {
7858
+ base: "var(--color-neutral-300)",
7859
+ emphasized: "var(--color-neutral-600)",
7860
+ proximityWeight: 0.5,
7861
+ },
7862
+ };
7863
+ const cvaSheetHandle = cvaMerge([
7864
+ "flex",
7865
+ "items-center",
7866
+ "justify-center",
7867
+ "w-full",
7868
+ "py-3",
7869
+ "touch-action-none",
7870
+ "select-none",
7871
+ "shrink-0",
7872
+ "bg-white",
7873
+ ], {
7874
+ variants: {
7875
+ isDragging: {
7876
+ true: ["cursor-grabbing", "[--handle-interaction:0.25]"],
7877
+ false: [
7878
+ "cursor-grab",
7879
+ "[--handle-interaction:0]",
7880
+ "hover:[--handle-interaction:0.15]",
7881
+ "active:[--handle-interaction:0.5]",
7882
+ ],
7883
+ },
7884
+ },
7885
+ defaultVariants: {
7886
+ isDragging: false,
7887
+ },
7888
+ });
7889
+ const cvaSheetHandleLine = cvaMerge(["w-8", "h-1", "rounded-full", "origin-center"], {
7890
+ variants: {
7891
+ isDragging: {
7892
+ true: [],
7893
+ false: ["[transition:width_150ms_ease,background-color_150ms_ease]"],
7894
+ },
7895
+ },
7896
+ defaultVariants: {
7897
+ isDragging: false,
7898
+ },
7899
+ });
7900
+ const handleBackgroundColor = `color-mix(in srgb, ${HANDLE_VISUALS.line.base} calc(100% - 100% * max(var(--sheet-dismiss-progress, 0), var(--sheet-snap-proximity, 0) * ${HANDLE_VISUALS.line.proximityWeight}, var(--handle-interaction, 0))), ${HANDLE_VISUALS.line.emphasized} calc(100% * max(var(--sheet-dismiss-progress, 0), var(--sheet-snap-proximity, 0) * ${HANDLE_VISUALS.line.proximityWeight}, var(--handle-interaction, 0))))`;
7901
+ /**
7902
+ * Visual drag indicator at the top of the Sheet.
7903
+ *
7904
+ * Renders a single handle line that narrows into a short stub as the user
7905
+ * drags toward the dismiss threshold, with color fading from neutral to dark.
7906
+ * The dismiss visual is driven entirely by the `--sheet-dismiss-progress`
7907
+ * CSS custom property set on the parent panel element — this component
7908
+ * has no dismiss-related state or logic.
7909
+ *
7910
+ * Accepts pointer event handlers from useSheetGestures and forwards them
7911
+ * to the handle element. The element has `touch-action: none` so pointer
7912
+ * events work reliably on touch devices.
7913
+ */
7914
+ const SheetHandle = ({ onPointerDown, onPointerMove, onPointerUp, onLostPointerCapture, onClick, isDragging = false, onMouseEnter, onMouseLeave, "data-testid": dataTestId, }) => {
7915
+ // Reaches 1 before dismiss-progress does (×1.3) so the visual
7916
+ // completes early, giving a snappy feel before the sheet actually closes.
7917
+ const dismissIntensity = "min(1, var(--sheet-dismiss-progress, 0) * 1.3)";
7918
+ // Width multiplier components (base 2rem = 32px, height is h-1 = 4px):
7919
+ // 1 → resting width: 32px
7920
+ // + stretch * 0.5 → widens up to 48px when over-pulling upward
7921
+ // - dismiss * 0.75 → shrinks to 0.25× = 8px at full dismiss (2× height, short stub)
7922
+ // + snap * 0.15 → subtle widen when approaching a snap point
7923
+ const handleWidth = `calc(2rem * (1 + var(--sheet-stretch-progress, 0) * 0.5 - ${dismissIntensity} * 0.75 + var(--sheet-snap-proximity, 0) * 0.15))`;
7924
+ return (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: {
7925
+ backgroundColor: handleBackgroundColor,
7926
+ width: handleWidth,
7927
+ } }) }));
7928
+ };
7929
+
7930
+ const cvaSheetOverlay = cvaMerge(["absolute", "inset-0", "transition-opacity", "duration-300", "[transition-timing-function:cubic-bezier(0.2,0,0,1)]"], {
7931
+ variants: {
7932
+ visible: {
7933
+ true: ["bg-black/50", "pointer-events-auto"],
7934
+ false: ["bg-transparent", "pointer-events-none"],
7935
+ },
7936
+ },
7937
+ defaultVariants: {
7938
+ visible: false,
7939
+ },
7940
+ });
7941
+ /**
7942
+ * Semi-transparent backdrop overlay for the Sheet.
7943
+ *
7944
+ * Fades in/out using CSS transitions. When not visible, the overlay is
7945
+ * transparent with pointer-events disabled so the background remains
7946
+ * interactable. Dismiss is handled by Floating UI's `useDismiss`
7947
+ * outside-press detection — clicks on the visible overlay bubble to the
7948
+ * document where `useDismiss` triggers the close flow.
7949
+ */
7950
+ const SheetOverlay = ({ visible, "data-testid": dataTestId }) => (jsx("div", { "aria-hidden": "true", className: cvaSheetOverlay({ visible }), "data-testid": dataTestId }));
7951
+
7952
+ const INITIAL_ANIMATION_STATE = {
7953
+ shouldRender: false,
7954
+ visuallyOpen: false,
7955
+ closePhase: "idle",
7956
+ prevIsOpen: false,
7957
+ };
7958
+ /** Reducer managing the Sheet component's mount/unmount animation lifecycle. */
7959
+ const sheetAnimationReducer = (state, action) => {
7960
+ switch (action.type) {
7961
+ case "SYNC_OPEN": {
7962
+ if (action.isOpen === state.prevIsOpen)
7963
+ return state;
7964
+ if (action.isOpen) {
7965
+ return {
7966
+ ...state,
7967
+ prevIsOpen: true,
7968
+ shouldRender: true,
7969
+ visuallyOpen: action.skipEntryAnimation,
7970
+ closePhase: "idle",
7971
+ };
7972
+ }
7973
+ return {
7974
+ ...state,
7975
+ prevIsOpen: false,
7976
+ visuallyOpen: false,
7977
+ closePhase: "collapsing",
7978
+ };
7979
+ }
7980
+ case "SET_VISUALLY_OPEN":
7981
+ return state.visuallyOpen ? state : { ...state, visuallyOpen: true };
7982
+ case "UNMOUNT":
7983
+ return state.shouldRender ? { ...state, shouldRender: false, closePhase: "idle" } : state;
7984
+ case "ENSURE_RENDER":
7985
+ return state.shouldRender ? state : { ...state, shouldRender: true };
7986
+ case "START_VANISH":
7987
+ return state.closePhase === "collapsing" ? { ...state, closePhase: "vanishing" } : state;
7988
+ default:
7989
+ return state;
7990
+ }
7991
+ };
7992
+
7993
+ /**
7994
+ * Manages the dismiss-intent visual on the sheet handle.
7995
+ *
7996
+ * Sets `--sheet-dismiss-progress: 1` on the panel element when the user
7997
+ * signals dismiss intent — either by holding Cmd/Ctrl+Shift while hovering
7998
+ * the handle, or while the close animation is in progress.
7999
+ *
8000
+ * This is the single owner of `--sheet-dismiss-progress` outside of the
8001
+ * gesture hook's drag flow. The gesture hook manages the property during
8002
+ * active drag (continuous 0-1 values); this hook manages it for the
8003
+ * binary on/off dismiss visual (modifier+hover, close animation).
8004
+ */
8005
+ const useSheetDismissIntent = ({ closable, closePhase, panelRef, }) => {
8006
+ const [closeModifierHeld, setCloseModifierHeld] = useState(false);
8007
+ const { hovering, onMouseEnter, onMouseLeave } = useHover({ debounced: false });
8008
+ useEffect(() => {
8009
+ if (!closable)
8010
+ return;
8011
+ const update = (e) => {
8012
+ setCloseModifierHeld((e.metaKey || e.ctrlKey) && e.shiftKey);
8013
+ };
8014
+ const reset = () => setCloseModifierHeld(false);
8015
+ window.addEventListener("keydown", update);
8016
+ window.addEventListener("keyup", update);
8017
+ window.addEventListener("blur", reset);
8018
+ return () => {
8019
+ window.removeEventListener("keydown", update);
8020
+ window.removeEventListener("keyup", update);
8021
+ window.removeEventListener("blur", reset);
8022
+ };
8023
+ }, [closable]);
8024
+ const showDismiss = closePhase !== "idle" || (closeModifierHeld && hovering && closable);
8025
+ useEffect(() => {
8026
+ const panel = panelRef.current;
8027
+ if (!panel)
8028
+ return;
8029
+ if (showDismiss) {
8030
+ panel.style.setProperty("--sheet-dismiss-progress", "1");
8031
+ }
8032
+ else {
8033
+ panel.style.removeProperty("--sheet-dismiss-progress");
8034
+ }
8035
+ }, [showDismiss, panelRef]);
8036
+ return useMemo(() => ({ onMouseEnter, onMouseLeave }), [onMouseEnter, onMouseLeave]);
8037
+ };
8038
+
8039
+ /** Minimum velocity (px/ms) to trigger directional snap selection. */
8040
+ const VELOCITY_THRESHOLD = 0.5;
8041
+ /** Dampening factor for rubber-band effect past snap bounds. */
8042
+ const RUBBER_BAND_FACTOR = 0.3;
8043
+ /**
8044
+ * Calculates vertical velocity from pointer position history.
8045
+ * Positive = downward, negative = upward.
8046
+ */
8047
+ const calculateVelocity = (samples) => {
8048
+ if (samples.length < 2)
8049
+ return 0;
8050
+ const first = samples[0];
8051
+ const last = samples[samples.length - 1];
8052
+ if (!first || !last)
8053
+ return 0;
8054
+ const dt = last.timestamp - first.timestamp;
8055
+ if (dt === 0)
8056
+ return 0;
8057
+ return (last.y - first.y) / dt;
8058
+ };
8059
+ /**
8060
+ * Applies rubber-band dampening when the effective height exceeds snap bounds.
8061
+ *
8062
+ * Within [minSnap, maxSnap] the raw drag delta passes through unchanged.
8063
+ * Beyond maxSnap the excess movement is dampened by RUBBER_BAND_FACTOR.
8064
+ *
8065
+ * Below minSnap behaviour depends on `closable`:
8066
+ * - **closable = true** — the sheet follows the finger freely (clamped at 0)
8067
+ * so the user can always reach the close threshold.
8068
+ * - **closable = false** — rubber-band dampening is applied (same feel as
8069
+ * the upward over-stretch) because the sheet cannot be dismissed.
8070
+ *
8071
+ * Returns the constrained effective height in pixels.
8072
+ */
8073
+ const computeConstrainedEffectiveHeight = (rawDelta, activeSnapHeight, minSnap, maxSnap, closable) => {
8074
+ const effectiveHeight = activeSnapHeight - rawDelta;
8075
+ if (effectiveHeight > maxSnap) {
8076
+ const excess = effectiveHeight - maxSnap;
8077
+ return maxSnap + excess * RUBBER_BAND_FACTOR;
8078
+ }
8079
+ if (effectiveHeight < minSnap) {
8080
+ if (closable) {
8081
+ return Math.max(0, effectiveHeight);
8082
+ }
8083
+ const deficit = minSnap - effectiveHeight;
8084
+ return minSnap - deficit * RUBBER_BAND_FACTOR;
8085
+ }
8086
+ return effectiveHeight;
8087
+ };
8088
+ /**
8089
+ * Computes how far the sheet has progressed toward dismissal (0 = at min snap, 1 = at close threshold).
8090
+ *
8091
+ * A dead zone below the min snap absorbs small overdrags before the dismiss
8092
+ * indicator starts. Returns 0 when close is not available.
8093
+ */
8094
+ const computeDismissProgress = (rawEffectiveHeight, minSnap, containerHeight, closeThresholdFraction, dismissDeadZonePx) => {
8095
+ const closeThreshold = minSnap - containerHeight * closeThresholdFraction;
8096
+ const adjustedMinSnap = minSnap - dismissDeadZonePx;
8097
+ const range = adjustedMinSnap - closeThreshold;
8098
+ if (range <= 0)
8099
+ return 0;
8100
+ return Math.max(0, Math.min(1, (adjustedMinSnap - rawEffectiveHeight) / range));
8101
+ };
8102
+ /**
8103
+ * Computes a 0-1 proximity value indicating how close the drag position is
8104
+ * to the nearest snap point in the current drag direction.
8105
+ *
8106
+ * 1 = directly at a snap point, 0 = further than `proximityRange` away.
8107
+ */
8108
+ const computeSnapProximity = (constrainedHeight, snapHeights, direction, proximityRange) => {
8109
+ let nearestApproachingDist = Infinity;
8110
+ for (const h of snapHeights) {
8111
+ const snapDir = h - constrainedHeight;
8112
+ if (direction * snapDir > 0) {
8113
+ const d = Math.abs(constrainedHeight - h);
8114
+ if (d < nearestApproachingDist)
8115
+ nearestApproachingDist = d;
8116
+ }
8117
+ }
8118
+ return nearestApproachingDist < Infinity ? Math.max(0, 1 - nearestApproachingDist / proximityRange) : 0;
8119
+ };
8120
+ /**
8121
+ * Resolves the target snap height based on effective position and velocity.
8122
+ *
8123
+ * - High upward velocity -> next larger snap above the current effective height
8124
+ * - High downward velocity -> next smaller snap below the current effective height
8125
+ * - Low velocity -> snap to the nearest point
8126
+ *
8127
+ * snapHeights must be sorted ascending.
8128
+ */
8129
+ const resolveSnapTarget = (effectiveHeight, snapHeights, velocity) => {
8130
+ if (snapHeights.length === 0)
8131
+ return 0;
8132
+ if (snapHeights.length === 1)
8133
+ return snapHeights[0] ?? 0;
8134
+ if (velocity < -VELOCITY_THRESHOLD) {
8135
+ for (const height of snapHeights) {
8136
+ if (height > effectiveHeight)
8137
+ return height;
8138
+ }
8139
+ return snapHeights[snapHeights.length - 1] ?? 0;
8140
+ }
8141
+ if (velocity > VELOCITY_THRESHOLD) {
8142
+ for (let i = snapHeights.length - 1; i >= 0; i--) {
8143
+ const height = snapHeights[i];
8144
+ if (height !== undefined && height < effectiveHeight)
8145
+ return height;
8146
+ }
8147
+ return snapHeights[0] ?? 0;
8148
+ }
8149
+ let nearest = snapHeights[0] ?? 0;
8150
+ let minDistance = Math.abs(effectiveHeight - nearest);
8151
+ for (const height of snapHeights) {
8152
+ const distance = Math.abs(effectiveHeight - height);
8153
+ if (distance < minDistance) {
8154
+ minDistance = distance;
8155
+ nearest = height;
8156
+ }
8157
+ }
8158
+ return nearest;
8159
+ };
8160
+
8161
+ /** Duration (ms) of the fade-out after the sheet arrives at a snap point. */
8162
+ const PROXIMITY_FADE_OUT_MS = 150;
8163
+ /**
8164
+ * Manages the `--sheet-snap-proximity` CSS custom property animation
8165
+ * during post-release snap transitions.
8166
+ *
8167
+ * Tracks the sheet height via `getBoundingClientRect` each frame,
8168
+ * computes proximity to the target snap, and fades the value out once
8169
+ * the sheet has arrived.
8170
+ */
8171
+ const useProximityAnimation = (sheetRef) => {
8172
+ const proximityRafRef = useRef(0);
8173
+ const cancelProximityAnimation = useCallback(() => {
8174
+ if (proximityRafRef.current !== 0) {
8175
+ cancelAnimationFrame(proximityRafRef.current);
8176
+ proximityRafRef.current = 0;
8177
+ }
8178
+ const sheet = sheetRef.current;
8179
+ if (sheet) {
8180
+ sheet.style.removeProperty("--sheet-snap-proximity");
8181
+ }
8182
+ }, [sheetRef]);
8183
+ const startProximityAnimation = useCallback((sheet, targetHeight) => {
8184
+ let fadeOutStart = null;
8185
+ const tick = (timestamp) => {
8186
+ const currentHeight = sheet.getBoundingClientRect().height;
8187
+ const distance = Math.abs(currentHeight - targetHeight);
8188
+ const proximity = Math.max(0, 1 - distance / SNAP_PROXIMITY_RANGE_PX);
8189
+ if (proximity >= 0.95 && fadeOutStart === null) {
8190
+ fadeOutStart = timestamp;
8191
+ }
8192
+ if (fadeOutStart !== null) {
8193
+ const elapsed = timestamp - fadeOutStart;
8194
+ const fadeProgress = Math.min(1, elapsed / PROXIMITY_FADE_OUT_MS);
8195
+ const fadedProximity = proximity * (1 - fadeProgress);
8196
+ if (fadeProgress >= 1) {
8197
+ sheet.style.removeProperty("--sheet-snap-proximity");
8198
+ proximityRafRef.current = 0;
8199
+ return;
8200
+ }
8201
+ sheet.style.setProperty("--sheet-snap-proximity", String(fadedProximity));
8202
+ }
8203
+ else {
8204
+ sheet.style.setProperty("--sheet-snap-proximity", String(proximity));
8205
+ }
8206
+ proximityRafRef.current = requestAnimationFrame(tick);
8207
+ };
8208
+ proximityRafRef.current = requestAnimationFrame(tick);
8209
+ }, []);
8210
+ useEffect(() => cancelProximityAnimation, [cancelProximityAnimation]);
8211
+ return useMemo(() => ({ cancelProximityAnimation, startProximityAnimation }), [cancelProximityAnimation, startProximityAnimation]);
8212
+ };
8213
+
8214
+ /** Number of pointer samples retained for velocity calculation. */
8215
+ const MAX_POINTER_SAMPLES = 5;
8216
+ /**
8217
+ * Maximum absolute pointer displacement (in px) for a gesture to qualify
8218
+ * as a tap/click rather than a drag. Keeps click detection robust on
8219
+ * touch screens where fingers wobble slightly.
8220
+ */
8221
+ const CLICK_MOVEMENT_THRESHOLD_PX = 5;
8222
+ /**
8223
+ * Minimum displacement (px) from the last direction-change position before
8224
+ * the committed drag direction flips. Prevents sub-pixel pointer jitter
8225
+ * from causing rapid direction toggles and visual flickering.
8226
+ */
8227
+ const DIRECTION_HYSTERESIS_PX = 2;
8228
+ /**
8229
+ * Prevents the spurious native click event that fires after a drag gesture.
8230
+ *
8231
+ * Pointer capture retargets mouseup to the same element as mousedown, so the
8232
+ * browser synthesizes a click even when the pointer traveled far. A one-time
8233
+ * capturing listener stops propagation before React's root delegation sees it.
8234
+ */
8235
+ const suppressNextClick = (target) => {
8236
+ target.addEventListener("click", ev => {
8237
+ ev.stopPropagation();
8238
+ }, { once: true, capture: true });
8239
+ };
8240
+ /**
8241
+ * Hook providing pointer-event-based drag gesture handling for a bottom sheet.
8242
+ *
8243
+ * Uses native Pointer Events with pointer capture for reliable cross-device
8244
+ * tracking. During drag, the hook directly manipulates the sheet element's
8245
+ * `--sheet-drag-height` CSS custom property and disables transitions for
8246
+ * immediate visual feedback. On release, it restores CSS transitions and
8247
+ * calls `onSnap` or `onClose` based on position and velocity.
8248
+ *
8249
+ * The consuming Sheet component should use `height: var(--sheet-drag-height, <snapHeight>)`
8250
+ * so the sheet stays anchored at the bottom while its height changes during drag.
8251
+ */
8252
+ const useSheetGestures = (props) => {
8253
+ const { snapHeights, activeSnapHeight, sheetRef, onSnap, onClose, containerHeight, enabled } = props;
8254
+ const [isDragging, setIsDragging] = useState(false);
8255
+ const isDraggingRef = useRef(false);
8256
+ const dragStartYRef = useRef(0);
8257
+ const maxDisplacementRef = useRef(0);
8258
+ const directionAnchorRef = useRef(0);
8259
+ const dragDirectionRef = useRef(0);
8260
+ const pointerSamplesRef = useRef([]);
8261
+ const onSnapRef = useRef(onSnap);
8262
+ const onCloseRef = useRef(onClose);
8263
+ const snapHeightsRef = useRef(snapHeights);
8264
+ const activeSnapHeightRef = useRef(activeSnapHeight);
8265
+ const containerHeightRef = useRef(containerHeight);
8266
+ useLayoutEffect(() => {
8267
+ onSnapRef.current = onSnap;
8268
+ onCloseRef.current = onClose;
8269
+ snapHeightsRef.current = snapHeights;
8270
+ if (!isDraggingRef.current) {
8271
+ activeSnapHeightRef.current = activeSnapHeight;
8272
+ }
8273
+ containerHeightRef.current = containerHeight;
8274
+ });
8275
+ const { cancelProximityAnimation, startProximityAnimation } = useProximityAnimation(sheetRef);
8276
+ const resetDragState = useCallback(() => {
8277
+ if (!isDraggingRef.current)
8278
+ return;
8279
+ isDraggingRef.current = false;
8280
+ setIsDragging(false);
8281
+ pointerSamplesRef.current = [];
8282
+ const sheet = sheetRef.current;
8283
+ if (sheet) {
8284
+ sheet.style.removeProperty("transition");
8285
+ sheet.style.removeProperty("--sheet-drag-height");
8286
+ sheet.style.removeProperty("--sheet-stretch-progress");
8287
+ sheet.style.removeProperty("--sheet-snap-proximity");
8288
+ sheet.style.removeProperty("--sheet-dismiss-progress");
8289
+ }
8290
+ }, [sheetRef]);
8291
+ const onLostPointerCapture = useCallback(() => {
8292
+ resetDragState();
8293
+ }, [resetDragState]);
8294
+ const onPointerDown = useCallback((e) => {
8295
+ if (!enabled || e.button !== 0 || snapHeightsRef.current.length === 0) {
8296
+ return;
8297
+ }
8298
+ e.currentTarget.setPointerCapture(e.pointerId);
8299
+ cancelProximityAnimation();
8300
+ dragStartYRef.current = e.clientY;
8301
+ maxDisplacementRef.current = 0;
8302
+ directionAnchorRef.current = activeSnapHeightRef.current;
8303
+ dragDirectionRef.current = 0;
8304
+ pointerSamplesRef.current = [{ y: e.clientY, timestamp: e.timeStamp }];
8305
+ isDraggingRef.current = true;
8306
+ setIsDragging(true);
8307
+ const sheet = sheetRef.current;
8308
+ if (sheet) {
8309
+ sheet.style.transition = "none";
8310
+ }
8311
+ }, [enabled, sheetRef, cancelProximityAnimation]);
8312
+ const onPointerMove = useCallback((e) => {
8313
+ if (!isDraggingRef.current)
8314
+ return;
8315
+ const rawDelta = e.clientY - dragStartYRef.current;
8316
+ maxDisplacementRef.current = Math.max(maxDisplacementRef.current, Math.abs(rawDelta));
8317
+ const samples = pointerSamplesRef.current;
8318
+ samples.push({ y: e.clientY, timestamp: e.timeStamp });
8319
+ if (samples.length > MAX_POINTER_SAMPLES) {
8320
+ samples.shift();
8321
+ }
8322
+ const snaps = snapHeightsRef.current;
8323
+ const minSnap = snaps[0] ?? 0;
8324
+ const maxSnap = snaps[snaps.length - 1] ?? 0;
8325
+ const closable = onCloseRef.current !== undefined;
8326
+ const constrainedHeight = computeConstrainedEffectiveHeight(rawDelta, activeSnapHeightRef.current, minSnap, maxSnap, closable);
8327
+ const sheet = sheetRef.current;
8328
+ if (sheet) {
8329
+ sheet.style.setProperty("--sheet-drag-height", `${constrainedHeight}px`);
8330
+ const rawEffective = activeSnapHeightRef.current - rawDelta;
8331
+ if (rawEffective > maxSnap) {
8332
+ const stretchAmount = rawEffective - maxSnap;
8333
+ const stretchProgress = Math.min(1, stretchAmount / 80);
8334
+ sheet.style.setProperty("--sheet-stretch-progress", String(stretchProgress));
8335
+ sheet.style.removeProperty("--sheet-dismiss-progress");
8336
+ sheet.style.removeProperty("--sheet-snap-proximity");
8337
+ }
8338
+ else if (rawEffective < minSnap && !closable) {
8339
+ const stretchAmount = minSnap - rawEffective;
8340
+ const stretchProgress = Math.min(1, stretchAmount / 80);
8341
+ sheet.style.setProperty("--sheet-stretch-progress", String(stretchProgress));
8342
+ sheet.style.removeProperty("--sheet-dismiss-progress");
8343
+ sheet.style.removeProperty("--sheet-snap-proximity");
8344
+ }
8345
+ else {
8346
+ sheet.style.removeProperty("--sheet-stretch-progress");
8347
+ let dismissProgress = 0;
8348
+ if (closable) {
8349
+ dismissProgress = computeDismissProgress(rawEffective, minSnap, containerHeightRef.current, CLOSE_THRESHOLD_FRACTION, DISMISS_DEAD_ZONE_PX);
8350
+ sheet.style.setProperty("--sheet-dismiss-progress", String(dismissProgress));
8351
+ }
8352
+ const displacement = constrainedHeight - directionAnchorRef.current;
8353
+ if (displacement > DIRECTION_HYSTERESIS_PX && dragDirectionRef.current !== 1) {
8354
+ dragDirectionRef.current = 1;
8355
+ directionAnchorRef.current = constrainedHeight;
8356
+ }
8357
+ else if (displacement < -DIRECTION_HYSTERESIS_PX && dragDirectionRef.current !== -1) {
8358
+ dragDirectionRef.current = -1;
8359
+ directionAnchorRef.current = constrainedHeight;
8360
+ }
8361
+ if (dismissProgress > 0) {
8362
+ sheet.style.removeProperty("--sheet-snap-proximity");
8363
+ }
8364
+ else {
8365
+ const snapProximity = computeSnapProximity(constrainedHeight, snaps, dragDirectionRef.current, SNAP_PROXIMITY_RANGE_PX);
8366
+ sheet.style.setProperty("--sheet-snap-proximity", String(snapProximity));
8367
+ }
8368
+ }
8369
+ }
8370
+ }, [sheetRef]);
8371
+ const onPointerUp = useCallback((e) => {
8372
+ if (!isDraggingRef.current)
8373
+ return;
8374
+ isDraggingRef.current = false;
8375
+ setIsDragging(false);
8376
+ const sheet = sheetRef.current;
8377
+ const wasClick = maxDisplacementRef.current < CLICK_MOVEMENT_THRESHOLD_PX;
8378
+ if (wasClick) {
8379
+ pointerSamplesRef.current = [];
8380
+ if (sheet) {
8381
+ sheet.style.removeProperty("transition");
8382
+ sheet.style.removeProperty("--sheet-drag-height");
8383
+ sheet.style.removeProperty("--sheet-stretch-progress");
8384
+ sheet.style.removeProperty("--sheet-snap-proximity");
8385
+ }
8386
+ return;
8387
+ }
8388
+ suppressNextClick(e.currentTarget);
8389
+ const rawDelta = e.clientY - dragStartYRef.current;
8390
+ const velocity = calculateVelocity(pointerSamplesRef.current);
8391
+ pointerSamplesRef.current = [];
8392
+ const effectiveHeight = activeSnapHeightRef.current - rawDelta;
8393
+ const snaps = snapHeightsRef.current;
8394
+ const minSnap = snaps[0] ?? 0;
8395
+ const closeThreshold = minSnap - containerHeightRef.current * CLOSE_THRESHOLD_FRACTION;
8396
+ if (effectiveHeight < closeThreshold || (velocity > VELOCITY_THRESHOLD && effectiveHeight < minSnap)) {
8397
+ if (sheet) {
8398
+ sheet.style.removeProperty("--sheet-drag-height");
8399
+ sheet.style.removeProperty("--sheet-stretch-progress");
8400
+ sheet.style.removeProperty("--sheet-snap-proximity");
8401
+ sheet.style.setProperty("--sheet-dismiss-progress", "1");
8402
+ }
8403
+ const closeHandler = onCloseRef.current;
8404
+ if (closeHandler) {
8405
+ closeHandler();
8406
+ return;
8407
+ }
8408
+ onSnapRef.current(minSnap);
8409
+ return;
8410
+ }
8411
+ const targetHeight = resolveSnapTarget(effectiveHeight, snaps, velocity);
8412
+ if (sheet) {
8413
+ sheet.style.removeProperty("--sheet-drag-height");
8414
+ sheet.style.removeProperty("--sheet-dismiss-progress");
8415
+ sheet.style.removeProperty("--sheet-stretch-progress");
8416
+ startProximityAnimation(sheet, targetHeight);
8417
+ }
8418
+ onSnapRef.current(targetHeight);
8419
+ }, [sheetRef, startProximityAnimation]);
8420
+ return useMemo(() => ({
8421
+ onPointerDown,
8422
+ onPointerMove,
8423
+ onPointerUp,
8424
+ onLostPointerCapture,
8425
+ isDragging,
8426
+ }), [onPointerDown, onPointerMove, onPointerUp, onLostPointerCapture, isDragging]);
8427
+ };
8428
+
8429
+ /**
8430
+ * Manages the Sheet's mount/unmount animation lifecycle.
8431
+ *
8432
+ * - Ensures `shouldRender` is set when re-opening after a stale transitionend closure.
8433
+ * - Triggers the open animation by forcing the browser to acknowledge the
8434
+ * off-screen `translateY(100%)` position (via `getComputedStyle`) before
8435
+ * dispatching `SET_VISUALLY_OPEN`, which changes the inline transform to
8436
+ * `translateY(0)`. The existing CSS transition on `transform` then animates
8437
+ * the slide-up smoothly.
8438
+ * - Handles `transitionend` on the panel to unmount after the close transition.
8439
+ */
8440
+ const useSheetLifecycle = ({ isOpen, entryAnimation, visuallyOpen, variant, container, panelRef, panelEl, dispatch, onCloseComplete, fitHeightReady, }) => {
8441
+ useWatch({
8442
+ value: isOpen,
8443
+ onChange: value => {
8444
+ if (value === true) {
8445
+ dispatch({ type: "ENSURE_RENDER" });
8446
+ }
8447
+ },
8448
+ immediate: true,
8449
+ });
8450
+ useLayoutEffect(() => {
8451
+ if (!isOpen || !entryAnimation || visuallyOpen || !container || !fitHeightReady)
8452
+ return;
8453
+ const panel = panelRef.current;
8454
+ if (!panel)
8455
+ return;
8456
+ void getComputedStyle(panel).transform;
8457
+ dispatch({ type: "SET_VISUALLY_OPEN" });
8458
+ }, [isOpen, entryAnimation, visuallyOpen, container, fitHeightReady, dispatch, panelRef, panelEl]);
8459
+ const isOpenRef = useRef(isOpen);
8460
+ useEffect(() => {
8461
+ isOpenRef.current = isOpen;
8462
+ }, [isOpen]);
8463
+ const variantRef = useRef(variant);
8464
+ useEffect(() => {
8465
+ variantRef.current = variant;
8466
+ }, [variant]);
8467
+ const onCloseCompleteRef = useRef(onCloseComplete);
8468
+ useEffect(() => {
8469
+ onCloseCompleteRef.current = onCloseComplete;
8470
+ }, [onCloseComplete]);
8471
+ const handleTransitionEnd = useCallback((e) => {
8472
+ if (e.target !== e.currentTarget || isOpenRef.current)
8473
+ return;
8474
+ switch (e.propertyName) {
8475
+ case "height":
8476
+ if (variantRef.current === "floating") {
8477
+ dispatch({ type: "START_VANISH" });
8478
+ }
8479
+ else {
8480
+ dispatch({ type: "UNMOUNT" });
8481
+ onCloseCompleteRef.current?.();
8482
+ }
8483
+ break;
8484
+ case "opacity":
8485
+ dispatch({ type: "UNMOUNT" });
8486
+ onCloseCompleteRef.current?.();
8487
+ break;
8488
+ }
8489
+ }, [dispatch]);
8490
+ return useMemo(() => ({ handleTransitionEnd }), [handleTransitionEnd]);
8491
+ };
8492
+
8493
+ const EMPTY_SNAP_HEIGHTS = [];
8494
+ const sortAscending = (a, b) => a - b;
8495
+ /**
8496
+ * Computes the active snap height in pixels for the current display state.
8497
+ *
8498
+ * Docked → measured docked height.
8499
+ * Fit mode with a measured height → use that height.
8500
+ * Otherwise → look up the level height from the precomputed array.
8501
+ */
8502
+ const computeActiveSnapHeight = (sizingMode, fitHeight, levelHeights, displayLevel, dockedHeightPx = DOCKED_HEIGHT_PX) => {
8503
+ if (sizingMode === "docked")
8504
+ return dockedHeightPx;
8505
+ if (sizingMode === "fit" && fitHeight > 0)
8506
+ return fitHeight;
8507
+ return levelHeights[displayLevel] ?? 0;
8508
+ };
8509
+ /**
8510
+ * Builds the set of pixel heights the gesture system snaps to.
8511
+ *
8512
+ * - Snapping disabled → empty array.
8513
+ * - Fit mode with measured height → `[dockedHeight?, fitHeight]`.
8514
+ * - Fit mode before measurement → falls back to level-based snaps.
8515
+ * - Normal mode → level heights with optional docked height prepended.
8516
+ *
8517
+ * The returned array is always sorted ascending.
8518
+ */
8519
+ const computeGestureSnapHeights = (effectiveSnapping, sizingMode, fitHeight, levelHeights, dockingEnabled, dockedHeightPx = DOCKED_HEIGHT_PX) => {
8520
+ if (!effectiveSnapping)
8521
+ return EMPTY_SNAP_HEIGHTS;
8522
+ if (sizingMode === "fit") {
8523
+ if (fitHeight <= 0) {
8524
+ const fallbackHeights = [...levelHeights];
8525
+ if (dockingEnabled) {
8526
+ fallbackHeights.unshift(dockedHeightPx);
8527
+ }
8528
+ return fallbackHeights.length > 0 ? fallbackHeights.toSorted(sortAscending) : EMPTY_SNAP_HEIGHTS;
8529
+ }
8530
+ const fitHeights = [fitHeight, ...levelHeights];
8531
+ if (dockingEnabled) {
8532
+ fitHeights.push(dockedHeightPx);
8533
+ }
8534
+ return fitHeights.toSorted(sortAscending);
8535
+ }
8536
+ const heights = [...levelHeights];
8537
+ if (dockingEnabled) {
8538
+ heights.unshift(dockedHeightPx);
8539
+ }
8540
+ return heights.toSorted(sortAscending);
8541
+ };
8542
+
8543
+ /**
8544
+ * Temporarily overrides an element's styles to measure its natural content
8545
+ * height, then restores the originals. Extracted from the hook body so the
8546
+ * lint rule for hook-parameter immutability does not flag the mutations.
8547
+ */
8548
+ const measureFallback = (el) => {
8549
+ const prevHeight = el.style.height;
8550
+ const prevOverflow = el.style.overflow;
8551
+ const prevTransition = el.style.transition;
8552
+ el.style.height = "auto";
8553
+ el.style.overflow = "visible";
8554
+ el.style.transition = "none";
8555
+ const height = el.scrollHeight;
8556
+ const fallbackStyle = getComputedStyle(el);
8557
+ const totalBorder = (parseFloat(fallbackStyle.borderTopWidth) || 0) + (parseFloat(fallbackStyle.borderBottomWidth) || 0);
8558
+ el.style.height = prevHeight;
8559
+ el.style.overflow = prevOverflow;
8560
+ el.style.transition = prevTransition;
8561
+ return height + totalBorder;
8562
+ };
8563
+ /**
8564
+ * Clones the element, measures its natural content height via scrollHeight,
8565
+ * and commits the result to the provided setter. Falls back to a temporary
8566
+ * inline-style override if the clone reports zero (e.g. in jsdom).
8567
+ */
8568
+ const measureAndCommit = (element, commit) => {
8569
+ const clone = element.cloneNode(true);
8570
+ if (!(clone instanceof HTMLDivElement))
8571
+ return;
8572
+ clone.style.cssText = `
8573
+ position: absolute;
8574
+ visibility: hidden;
8575
+ height: auto;
8576
+ overflow: visible;
8577
+ pointer-events: none;
8578
+ `;
8579
+ const container = element.parentElement ?? document.body;
8580
+ container.appendChild(clone);
8581
+ const cloneScrollH = clone.scrollHeight;
8582
+ let measured;
8583
+ if (cloneScrollH > 0) {
8584
+ const cloneStyle = getComputedStyle(clone);
8585
+ measured =
8586
+ cloneScrollH + (parseFloat(cloneStyle.borderTopWidth) || 0) + (parseFloat(cloneStyle.borderBottomWidth) || 0);
8587
+ }
8588
+ else {
8589
+ measured = measureFallback(element);
8590
+ }
8591
+ clone.remove();
8592
+ commit(measured);
8593
+ };
8594
+ /**
8595
+ * Measures the panel's natural content height when docked and persists it for
8596
+ * the next dock transition.
8597
+ *
8598
+ * The stored "last measured" height is used as the animation target when
8599
+ * transitioning from expanded to docked, so the sheet animates smoothly to
8600
+ * the correct height. If no value has been stored yet (first dock), the
8601
+ * consumer falls back to DOCKED_HEIGHT_PX — no animation is acceptable.
8602
+ *
8603
+ * When transitioning from expanded to docked, the panel retains its previous
8604
+ * height during layout, so scrollHeight returns the expanded value. We measure
8605
+ * via a hidden clone (height: auto) so we never mutate the panel — the height
8606
+ * transition runs without disruption.
8607
+ *
8608
+ * The value is intentionally NOT reset when leaving docked mode, so re-entry
8609
+ * always has the correct target for the animation.
8610
+ *
8611
+ * Does NOT use a ResizeObserver because the gesture system manipulates the
8612
+ * element's CSS height during drag, which inflates `scrollHeight` beyond the
8613
+ * true content size and would create a feedback loop.
8614
+ *
8615
+ * Returns 0 before the first measurement. The consumer falls back to
8616
+ * DOCKED_HEIGHT_PX until a positive value is available.
8617
+ */
8618
+ const useDockedContentHeight = (element, shouldRender, sizingMode) => {
8619
+ const [lastMeasuredHeight, setLastMeasuredHeight] = useState(0);
8620
+ useLayoutEffect(() => {
8621
+ if (!shouldRender || sizingMode !== "docked" || !element)
8622
+ return undefined;
8623
+ measureAndCommit(element, setLastMeasuredHeight);
8624
+ return undefined;
8625
+ }, [shouldRender, sizingMode, element]);
8626
+ return lastMeasuredHeight;
8627
+ };
8628
+
8629
+ /**
8630
+ * Measures the panel's natural content height for fit mode via ResizeObserver.
8631
+ *
8632
+ * Only active when `sizingMode` is `"fit"` and the panel is visible (docked
8633
+ * height is measured separately by useDockedContentHeight).
8634
+ *
8635
+ * Measures the sum of children's `scrollHeight` rather than the panel's own
8636
+ * `scrollHeight`. This is necessary because children with their own scroll
8637
+ * context absorb overflow, which would make the panel's `scrollHeight` equal
8638
+ * its `clientHeight` (creating a feedback loop where the height shrinks by
8639
+ * the border size each cycle).
8640
+ *
8641
+ * Relies on the scroll area not having `flex-grow` in fit mode (controlled
8642
+ * by the `fillHeight` CVA variant on the scroll area element). Without this,
8643
+ * a flex-grown scroll area at expanded height would report its stretched
8644
+ * `clientHeight` as `scrollHeight`, making the measured fit height equal
8645
+ * the expanded height.
8646
+ *
8647
+ * The initial measurement sets the height directly. Subsequent
8648
+ * ResizeObserver callbacks only allow the height to increase, preventing
8649
+ * stacking overrides or other transient constraints from permanently
8650
+ * reducing the measured height.
8651
+ *
8652
+ * Accepts the DOM element directly (rather than a RefObject) so that the
8653
+ * layoutEffect re-runs whenever the element becomes available — critical
8654
+ * because FloatingPortal defers its first render, meaning the element is
8655
+ * null on the first layout pass and only non-null after FloatingPortal's
8656
+ * own layoutEffect fires and the Card mounts.
8657
+ */
8658
+ const useSheetFitHeight = (element, sizingMode, shouldRender) => {
8659
+ const [fitHeight, setFitHeight] = useState(0);
8660
+ useLayoutEffect(() => {
8661
+ if (sizingMode !== "fit" || !shouldRender || !element)
8662
+ return undefined;
8663
+ const measureChildren = () => {
8664
+ let total = 0;
8665
+ for (let i = 0; i < element.children.length; i++) {
8666
+ total += element.children[i]?.scrollHeight ?? 0;
8667
+ }
8668
+ const style = getComputedStyle(element);
8669
+ total += parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
8670
+ return total;
8671
+ };
8672
+ let isInitialObservation = true;
8673
+ const observer = new ResizeObserver(() => {
8674
+ const height = measureChildren();
8675
+ if (isInitialObservation) {
8676
+ isInitialObservation = false;
8677
+ setFitHeight(height);
8678
+ }
8679
+ else {
8680
+ setFitHeight(prev => Math.max(prev, height));
8681
+ }
8682
+ });
8683
+ observer.observe(element);
8684
+ return () => observer.disconnect();
8685
+ }, [sizingMode, shouldRender, element]);
8686
+ // Return 0 when the panel element is not mounted so that fitHeightReady stays
8687
+ // false until the panel is actually in the DOM. Without this, a stale fitHeight
8688
+ // from a previous open would make fitHeightReady=true immediately on re-open,
8689
+ // causing SET_VISUALLY_OPEN to dispatch before the browser has painted the
8690
+ // initial translateY(100%) baseline — resulting in no visible animation
8691
+ // (sheet just appears at the open position) on subsequent opens.
8692
+ return element !== null ? fitHeight : 0;
8693
+ };
8694
+
8695
+ /**
8696
+ * Consolidates container/panel measurement, docked/fit height tracking,
8697
+ * the `onGeometryChange` feedback loop, and all derived snap geometry that Sheet
8698
+ * needs for gestures and CSS sizing.
8699
+ */
8700
+ const useSheetMeasurements = ({ shouldRender, state, dockingEnabled, snapping, externalRef, onGeometryChange, onSnap, }) => {
8701
+ const { geometry: containerGeometry, ref: containerRef } = useMeasure();
8702
+ const containerHeight = containerGeometry?.height ?? 0;
8703
+ const { ref: panelRef, element: panelEl } = useMeasure({ skip: !shouldRender });
8704
+ const mergedPanelRef = useMergeRefs([panelRef, externalRef]);
8705
+ const panelRefObj = useRef(null);
8706
+ useLayoutEffect(() => {
8707
+ panelRefObj.current = panelEl ?? null;
8708
+ }, [panelEl]);
8709
+ const lastMeasuredDockedHeight = useDockedContentHeight(panelEl, shouldRender, state.sizingMode);
8710
+ const fitHeight = useSheetFitHeight(panelEl, state.sizingMode, shouldRender);
8711
+ const fitHeightReady = state.sizingMode !== "fit" || fitHeight > 0;
8712
+ useEffect(() => {
8713
+ onGeometryChange({ containerHeight, fitHeight, dockedHeight: lastMeasuredDockedHeight }, dockingEnabled);
8714
+ }, [onGeometryChange, containerHeight, fitHeight, lastMeasuredDockedHeight, dockingEnabled]);
8715
+ const maxSheetHeightPx = Math.max(0, containerHeight - FULL_HEIGHT_TOP_MARGIN_PX);
8716
+ const cappedFitHeight = containerHeight > 0 && fitHeight > 0 ? Math.min(fitHeight, maxSheetHeightPx) : fitHeight;
8717
+ const activeSnapHeightPx = computeActiveSnapHeight(state.sizingMode, cappedFitHeight, state.levelHeights, state.level, state.effectiveDockedHeight);
8718
+ const gestureSnapHeights = useMemo(() => computeGestureSnapHeights(snapping, state.sizingMode, cappedFitHeight, state.levelHeights, dockingEnabled, state.effectiveDockedHeight), [snapping, state.sizingMode, cappedFitHeight, state.levelHeights, dockingEnabled, state.effectiveDockedHeight]);
8719
+ const handleGestureSnap = useCallback((heightPx) => {
8720
+ onSnap(heightPx, state.sizingMode === "fit" && fitHeight <= 0);
8721
+ }, [onSnap, state.sizingMode, fitHeight]);
8722
+ return useMemo(() => ({
8723
+ containerRef,
8724
+ mergedPanelRef,
8725
+ panelRefObj,
8726
+ panelEl: panelEl ?? null,
8727
+ containerHeight,
8728
+ activeSnapHeightPx,
8729
+ gestureSnapHeights,
8730
+ cappedFitHeight,
8731
+ fitHeightReady,
8732
+ fitHeight,
8733
+ handleGestureSnap,
8734
+ }), [
8735
+ containerRef,
8736
+ mergedPanelRef,
8737
+ panelRefObj,
8738
+ panelEl,
8739
+ containerHeight,
8740
+ activeSnapHeightPx,
8741
+ gestureSnapHeights,
8742
+ cappedFitHeight,
8743
+ fitHeightReady,
8744
+ fitHeight,
8745
+ handleGestureSnap,
8746
+ ]);
8747
+ };
8748
+
8749
+ /**
8750
+ * Blocks scrolling on the document and compensates for scrollbar width to prevent layout shift.
8751
+ * Uses scrollbar-gutter: stable to reserve space for the scrollbar when hiding overflow,
8752
+ * but only if a scrollbar was actually visible before blocking.
8753
+ * This only has an effect with classic scrollbars - overlay scrollbars (macOS default) are unaffected.
8754
+ * Returns the original styles so they can be restored later.
8755
+ *
8756
+ * @returns {OriginalStyles} The original styles before blocking
8757
+ */
8758
+ const blockDocumentScroll = () => {
8759
+ const { body } = document;
8760
+ const html = document.documentElement;
8761
+ // Check if there's a visible scrollbar before we hide it
8762
+ // The scrollbar can be on either <html> or <body> depending on CSS setup
8763
+ // (e.g., Storybook sets overflow:hidden on html and overflow:auto on body)
8764
+ // Check html scrollbar: window.innerWidth includes scrollbar, clientWidth doesn't
8765
+ const htmlScrollbarWidth = window.innerWidth - html.clientWidth;
8766
+ // Check body scrollbar: offsetWidth includes border+scrollbar, clientWidth excludes both
8767
+ const bodyStyle = window.getComputedStyle(body);
8768
+ const bodyBorderLeft = parseInt(bodyStyle.borderLeftWidth) || 0;
8769
+ const bodyBorderRight = parseInt(bodyStyle.borderRightWidth) || 0;
8770
+ const bodyScrollbarWidth = body.offsetWidth - bodyBorderLeft - bodyBorderRight - body.clientWidth;
8771
+ // Use whichever scrollbar is present
8772
+ const hasVisibleScrollbar = htmlScrollbarWidth > 0 || bodyScrollbarWidth > 0;
8773
+ // Store original values before modifying
8774
+ const originalStyles = {
8775
+ html: {
8776
+ position: html.style.position,
8777
+ overflow: html.style.overflow,
8778
+ },
8779
+ body: {
8780
+ position: body.style.position,
8781
+ overflow: body.style.overflow,
8782
+ scrollbarGutter: body.style.scrollbarGutter,
8783
+ },
8784
+ };
8785
+ // Block scroll on both html and body for cross-browser compatibility
8786
+ html.style.position = "relative";
8787
+ html.style.overflow = "hidden";
8788
+ body.style.position = "relative";
8789
+ body.style.overflow = "hidden";
8790
+ // Apply scrollbar-gutter on body (where the scrollbar typically lives when content scrolls)
8791
+ // This reserves space for the scrollbar even when overflow is hidden, preventing layout shift.
8792
+ // Only has effect with classic scrollbars - overlay scrollbars (macOS default) are unaffected.
8793
+ if (hasVisibleScrollbar) {
8794
+ body.style.scrollbarGutter = "stable";
8795
+ }
8796
+ return originalStyles;
8797
+ };
8798
+ /**
8799
+ * Restores document scrolling by restoring the provided original styles.
8800
+ *
8801
+ * @param originalStyles - The original styles to restore
8802
+ */
8803
+ const restoreDocumentScroll = (originalStyles) => {
8804
+ const { body } = document;
8805
+ const html = document.documentElement;
8806
+ // Restore original values instead of just clearing
8807
+ if (originalStyles.html) {
8808
+ html.style.position = originalStyles.html.position;
8809
+ html.style.overflow = originalStyles.html.overflow;
8810
+ }
8811
+ if (originalStyles.body) {
8812
+ body.style.position = originalStyles.body.position;
8813
+ body.style.overflow = originalStyles.body.overflow;
8814
+ body.style.scrollbarGutter = originalStyles.body.scrollbarGutter;
8815
+ }
8816
+ };
8817
+ /**
8818
+ * Blocks scrolling on a custom container element.
8819
+ * Uses scrollbar-gutter: stable to reserve space for the scrollbar when hiding overflow,
8820
+ * but only if a scrollbar was actually visible before blocking.
8821
+ * This only has an effect with classic scrollbars - overlay scrollbars (macOS default) are unaffected.
8822
+ * Returns the original styles so they can be restored later.
8823
+ *
8824
+ * @param container - The container element to block scroll on
8825
+ * @returns {OriginalStyles} The original styles before blocking
8826
+ */
8827
+ const blockContainerScroll = (container) => {
8828
+ // Check if there's a visible scrollbar before we hide it
8829
+ // offsetWidth includes border + scrollbar, clientWidth excludes both
8830
+ // We need to subtract borders to isolate the scrollbar width
8831
+ const style = window.getComputedStyle(container);
8832
+ const borderLeft = parseInt(style.borderLeftWidth) || 0;
8833
+ const borderRight = parseInt(style.borderRightWidth) || 0;
8834
+ const scrollbarWidth = container.offsetWidth - borderLeft - borderRight - container.clientWidth;
8835
+ const hasVisibleScrollbar = scrollbarWidth > 0;
8836
+ const originalStyles = {
8837
+ container: {
8838
+ overflow: container.style.overflow,
8839
+ scrollbarGutter: container.style.scrollbarGutter,
8840
+ },
8841
+ };
8842
+ container.style.overflow = "hidden";
8843
+ // Only add scrollbar-gutter if there was a visible scrollbar to preserve space for
8844
+ // This prevents adding unnecessary space when content doesn't overflow
8845
+ if (hasVisibleScrollbar) {
8846
+ container.style.scrollbarGutter = "stable";
8847
+ }
8848
+ return originalStyles;
8849
+ };
8850
+ /**
8851
+ * Restores container scrolling by restoring the provided original styles.
8852
+ *
8853
+ * @param container - The container element to restore scroll on
8854
+ * @param originalStyles - The original styles to restore
8855
+ */
8856
+ const restoreContainerScroll = (container, originalStyles) => {
8857
+ if (originalStyles.container) {
8858
+ container.style.overflow = originalStyles.container.overflow;
8859
+ container.style.scrollbarGutter = originalStyles.container.scrollbarGutter;
8860
+ }
8861
+ };
8862
+ /**
8863
+ * Hook that provides scroll blocking functionality.
8864
+ * This properly accounts for existing body padding to prevent layout shifts.
8865
+ *
8866
+ * Each instance gets its own stored original styles via refs, preventing
8867
+ * conflicts when multiple components are used on the same page. The hook also ensures
8868
+ * cleanup on unmount if scroll is still blocked.
8869
+ *
8870
+ * @param scrollContainer - The DOM element whose scroll should be blocked. Defaults to document.body.
8871
+ * @returns {{blockScroll: () => void, restoreScroll: () => void}} Object containing blockScroll and restoreScroll functions
8872
+ */
8873
+ const useScrollBlock = (scrollContainer = typeof document !== "undefined" ? document.body : null // default to document.body if no scroll container is provided
8874
+ ) => {
8875
+ const originalStylesRef = useRef(null);
8876
+ const isBlockedRef = useRef(false);
8877
+ /**
8878
+ * Blocks scrolling and stores original styles for restoration.
8879
+ * Blocks the document (body/html) if scroll container is the document body,
8880
+ * or blocks the custom container if a custom container is provided.
8881
+ */
8882
+ const blockScroll = useCallback(() => {
8883
+ if (isBlockedRef.current || !scrollContainer) {
8884
+ return; // Already blocked or no scroll container
8885
+ }
8886
+ if (scrollContainer === document.body) {
8887
+ originalStylesRef.current = blockDocumentScroll();
8888
+ }
8889
+ else {
8890
+ originalStylesRef.current = blockContainerScroll(scrollContainer);
8891
+ }
8892
+ isBlockedRef.current = true;
8893
+ }, [scrollContainer]);
8894
+ /**
8895
+ * Restores scrolling using the previously stored original styles.
8896
+ */
8897
+ const restoreScroll = useCallback(() => {
8898
+ if (!isBlockedRef.current || !scrollContainer || !originalStylesRef.current) {
8899
+ return;
8900
+ }
8901
+ if (scrollContainer === document.body) {
8902
+ restoreDocumentScroll(originalStylesRef.current);
8903
+ }
8904
+ else {
8905
+ restoreContainerScroll(scrollContainer, originalStylesRef.current);
8906
+ }
8907
+ originalStylesRef.current = null;
8908
+ isBlockedRef.current = false;
8909
+ }, [scrollContainer]);
8910
+ // Cleanup: restore scroll if component unmounts while scroll is blocked
8911
+ useEffect(() => {
8912
+ return () => {
8913
+ if (isBlockedRef.current && scrollContainer && originalStylesRef.current) {
8914
+ if (scrollContainer === document.body) {
8915
+ restoreDocumentScroll(originalStylesRef.current);
8916
+ }
8917
+ else {
8918
+ restoreContainerScroll(scrollContainer, originalStylesRef.current);
8919
+ }
8920
+ isBlockedRef.current = false;
8921
+ }
8922
+ };
8923
+ }, [scrollContainer]);
8924
+ return useMemo(() => ({ blockScroll, restoreScroll }), [blockScroll, restoreScroll]);
8925
+ };
8926
+
8927
+ /**
8928
+ * Manages scrollbar and separator-line visibility during sheet motion (drag
8929
+ * and CSS height transitions), freezing both to the state captured at motion
8930
+ * start to avoid visual flashing.
8931
+ *
8932
+ * Freeze rules:
8933
+ * - **Was overflowing at motion start** — scrollbar is forced visible
8934
+ * (`overflow-y: scroll`) and separator stays opaque throughout the motion.
8935
+ * - **Was NOT overflowing at motion start** — scrollbar is hidden via
8936
+ * `useScrollBlock` (overflow hidden + scrollbar-gutter to prevent reflow)
8937
+ * and separator stays transparent throughout the motion.
8938
+ * - **Was docked at motion start** — always treated as "not overflowing"
8939
+ * because the scroll area is not visible in docked state. Overflow is
8940
+ * hidden directly (no scrollbar-gutter) so no space is reserved for a
8941
+ * scrollbar that was never visible.
8942
+ *
8943
+ * At rest, overflow is detected via ResizeObserver + MutationObserver and
8944
+ * the separator's `style.opacity` is toggled (CSS transition on the element
8945
+ * provides the fade).
8946
+ */
8947
+ const useSheetMotionOverflow = ({ panelEl, isDragging, scrollAreaEl, separatorEl, isDocked, isClosing, }) => {
8948
+ const isTransitioningRef = useRef(false);
8949
+ const isDraggingRef = useRef(isDragging);
8950
+ const isMotionActiveRef = useRef(false);
8951
+ const wasOverflowingAtStartRef = useRef(false);
8952
+ const frozeFromDockedRef = useRef(false);
8953
+ const originalOverflowYRef = useRef("");
8954
+ const isDockedRef = useRef(isDocked);
8955
+ const isClosingRef = useRef(isClosing);
8956
+ const settledDockedRef = useRef(isDocked);
8957
+ const { blockScroll, restoreScroll } = useScrollBlock(scrollAreaEl);
8958
+ const blockScrollRef = useRef(blockScroll);
8959
+ const restoreScrollRef = useRef(restoreScroll);
8960
+ const scrollAreaElRef = useRef(scrollAreaEl);
8961
+ const separatorElRef = useRef(separatorEl);
8962
+ const checkOverflow = useCallback(() => {
8963
+ const el = scrollAreaElRef.current;
8964
+ if (!el)
8965
+ return false;
8966
+ return el.scrollHeight > el.clientHeight;
8967
+ }, []);
8968
+ const syncSeparator = useCallback(() => {
8969
+ if (isMotionActiveRef.current)
8970
+ return;
8971
+ const sep = separatorElRef.current;
8972
+ if (!sep)
8973
+ return;
8974
+ sep.style.opacity = checkOverflow() ? "1" : "";
8975
+ }, [checkOverflow]);
8976
+ const freeze = useCallback((forceHideOverflow = false) => {
8977
+ if (isMotionActiveRef.current)
8978
+ return;
8979
+ isMotionActiveRef.current = true;
8980
+ const fromDocked = settledDockedRef.current;
8981
+ const wasOverflowing = forceHideOverflow ? false : fromDocked ? false : checkOverflow();
8982
+ wasOverflowingAtStartRef.current = wasOverflowing;
8983
+ frozeFromDockedRef.current = fromDocked;
8984
+ const area = scrollAreaElRef.current;
8985
+ const sep = separatorElRef.current;
8986
+ if (wasOverflowing) {
8987
+ if (area) {
8988
+ originalOverflowYRef.current = area.style.overflowY;
8989
+ area.style.overflowY = "scroll";
8990
+ }
8991
+ if (sep)
8992
+ sep.style.opacity = "1";
8993
+ }
8994
+ else if (fromDocked) {
8995
+ if (area) {
8996
+ originalOverflowYRef.current = area.style.overflowY;
8997
+ area.style.overflowY = "hidden";
8998
+ }
8999
+ if (sep)
9000
+ sep.style.opacity = "";
9001
+ }
9002
+ else {
9003
+ blockScrollRef.current();
9004
+ if (sep)
9005
+ sep.style.opacity = "";
9006
+ }
9007
+ }, [checkOverflow]);
9008
+ const unfreeze = useCallback(() => {
9009
+ if (!isMotionActiveRef.current)
9010
+ return;
9011
+ isMotionActiveRef.current = false;
9012
+ const area = scrollAreaElRef.current;
9013
+ if (wasOverflowingAtStartRef.current || frozeFromDockedRef.current) {
9014
+ if (area) {
9015
+ area.style.overflowY = originalOverflowYRef.current;
9016
+ originalOverflowYRef.current = "";
9017
+ }
9018
+ }
9019
+ else {
9020
+ restoreScrollRef.current();
9021
+ }
9022
+ settledDockedRef.current = isDockedRef.current;
9023
+ syncSeparator();
9024
+ }, [syncSeparator]);
9025
+ useLayoutEffect(() => {
9026
+ const wasDocked = isDockedRef.current;
9027
+ const wasClosing = isClosingRef.current;
9028
+ blockScrollRef.current = blockScroll;
9029
+ restoreScrollRef.current = restoreScroll;
9030
+ scrollAreaElRef.current = scrollAreaEl;
9031
+ separatorElRef.current = separatorEl;
9032
+ isDockedRef.current = isDocked;
9033
+ isClosingRef.current = isClosing;
9034
+ if (wasDocked && !isDocked) {
9035
+ freeze();
9036
+ }
9037
+ if (!wasClosing && isClosing) {
9038
+ freeze(true);
9039
+ }
9040
+ syncSeparator();
9041
+ });
9042
+ useEffect(() => {
9043
+ isDraggingRef.current = isDragging;
9044
+ if (isDragging) {
9045
+ freeze();
9046
+ }
9047
+ else if (!isTransitioningRef.current) {
9048
+ unfreeze();
9049
+ }
9050
+ }, [isDragging, freeze, unfreeze]);
9051
+ useEffect(() => {
9052
+ if (!panelEl)
9053
+ return;
9054
+ const onStart = (e) => {
9055
+ if (e.target !== panelEl || e.propertyName !== "height")
9056
+ return;
9057
+ isTransitioningRef.current = true;
9058
+ freeze();
9059
+ };
9060
+ const onEnd = (e) => {
9061
+ if (e.target !== panelEl || e.propertyName !== "height")
9062
+ return;
9063
+ isTransitioningRef.current = false;
9064
+ if (!isDraggingRef.current)
9065
+ unfreeze();
9066
+ };
9067
+ const onCancel = (e) => {
9068
+ if (e.target !== panelEl || e.propertyName !== "height")
9069
+ return;
9070
+ isTransitioningRef.current = false;
9071
+ if (!isDraggingRef.current)
9072
+ unfreeze();
9073
+ };
9074
+ panelEl.addEventListener("transitionstart", onStart);
9075
+ panelEl.addEventListener("transitionend", onEnd);
9076
+ panelEl.addEventListener("transitioncancel", onCancel);
9077
+ return () => {
9078
+ panelEl.removeEventListener("transitionstart", onStart);
9079
+ panelEl.removeEventListener("transitionend", onEnd);
9080
+ panelEl.removeEventListener("transitioncancel", onCancel);
9081
+ };
9082
+ }, [panelEl, freeze, unfreeze]);
9083
+ useEffect(() => {
9084
+ if (!scrollAreaEl)
9085
+ return;
9086
+ const update = () => {
9087
+ syncSeparator();
9088
+ };
9089
+ update();
9090
+ const ro = new ResizeObserver(update);
9091
+ ro.observe(scrollAreaEl);
9092
+ const mo = new MutationObserver(update);
9093
+ mo.observe(scrollAreaEl, { childList: true, subtree: true, characterData: true });
9094
+ scrollAreaEl.addEventListener("scroll", update, { passive: true });
9095
+ return () => {
9096
+ ro.disconnect();
9097
+ mo.disconnect();
9098
+ scrollAreaEl.removeEventListener("scroll", update);
9099
+ };
9100
+ }, [scrollAreaEl, syncSeparator]);
9101
+ };
9102
+
9103
+ /**
9104
+ * Container-scoped bottom sheet with adaptive snap levels and gesture support.
9105
+ *
9106
+ * Use Sheet for contextual surfaces that slide up from the bottom of a
9107
+ * container -- action menus, detail panels, element settings, or filters.
9108
+ * Every Sheet renders via Portal into a required `container` element.
9109
+ * On small screens, consider using Sheet instead of a Popover for better
9110
+ * touch UX. For app-level blocking dialogs, use Modal instead (which
9111
+ * automatically renders as a Sheet on small screens).
9112
+ *
9113
+ * When `variant="modal"`, the sheet behaves as a dialog: it renders a
9114
+ * dimming backdrop, sets `role="dialog"` and `aria-modal` on the panel,
9115
+ * and traps keyboard focus via FloatingFocusManager. Pass
9116
+ * `trapFocus={false}` when a parent component provides its own focus
9117
+ * management (e.g. Modal in sheet mode).
9118
+ *
9119
+ * Snap levels are adaptive: the Sheet measures the container and creates
9120
+ * fewer stops in shorter containers. Consumers navigate with directional
9121
+ * methods (`snapUp`, `snapDown`, `expand`, `collapse`, `dock`) instead of
9122
+ * naming specific snap points.
9123
+ *
9124
+ * The outermost wrapper sets `container-type: size` so cqh/cqw units
9125
+ * resolve against the container's dimensions. Open/close animations use
9126
+ * CSS transitions on transform; the component stays mounted during the
9127
+ * close animation and unmounts after the transition completes.
9128
+ */
9129
+ const Sheet = ({ isOpen, state, snap, onGeometryChange, onSnap, onClickHandle, onCloseGesture, floatingUi, ref, anchor = "center", snapping = true, resizable = true, variant = "default", trapFocus = true, container, dockedContent, className, "data-testid": dataTestId, onCloseComplete, entryAnimation = "subsequent", children, }) => {
9130
+ const isFirstRender = useIsFirstRender();
9131
+ const skipEntryAnimation = entryAnimation === "never" || (entryAnimation === "subsequent" && isFirstRender);
9132
+ const effectiveSnapping = resizable && snapping;
9133
+ const [animState, animDispatch] = useReducer(sheetAnimationReducer, INITIAL_ANIMATION_STATE);
9134
+ if (isOpen !== animState.prevIsOpen) {
9135
+ animDispatch({
9136
+ type: "SYNC_OPEN",
9137
+ isOpen,
9138
+ skipEntryAnimation,
9139
+ });
9140
+ }
9141
+ const measurements = useSheetMeasurements({
9142
+ shouldRender: animState.shouldRender,
9143
+ state,
9144
+ dockingEnabled: dockedContent !== undefined,
9145
+ snapping: effectiveSnapping,
9146
+ externalRef: ref,
9147
+ onGeometryChange,
9148
+ onSnap,
9149
+ });
9150
+ const { containerRef, mergedPanelRef, panelRefObj, panelEl, containerHeight, activeSnapHeightPx, gestureSnapHeights, cappedFitHeight, fitHeightReady, fitHeight, handleGestureSnap, } = measurements;
9151
+ const gestures = useSheetGestures({
9152
+ activeSnapHeight: activeSnapHeightPx,
9153
+ containerHeight,
9154
+ enabled: animState.shouldRender && containerHeight > 0 && effectiveSnapping,
9155
+ onClose: onCloseGesture,
9156
+ onSnap: handleGestureSnap,
9157
+ sheetRef: panelRefObj,
9158
+ snapHeights: gestureSnapHeights,
9159
+ });
9160
+ const [scrollAreaEl, setScrollAreaEl] = useState(null);
9161
+ const [separatorEl, setSeparatorEl] = useState(null);
9162
+ useSheetMotionOverflow({
9163
+ panelEl,
9164
+ isDragging: gestures.isDragging,
9165
+ scrollAreaEl,
9166
+ separatorEl,
9167
+ isDocked: state.sizingMode === "docked",
9168
+ isClosing: animState.closePhase !== "idle",
9169
+ });
9170
+ const { onMouseEnter, onMouseLeave } = useSheetDismissIntent({
9171
+ closable: onCloseGesture !== undefined,
9172
+ closePhase: animState.closePhase,
9173
+ panelRef: panelRefObj,
9174
+ });
9175
+ const shouldTrapFocus = variant === "modal" && trapFocus && floatingUi !== undefined;
9176
+ const panelRefWithFloating = useMergeRefs$1([mergedPanelRef, floatingUi?.refs.setFloating ?? null]);
9177
+ const handleClick = useCallback((e) => {
9178
+ const mod = e.metaKey || e.ctrlKey;
9179
+ if (mod && e.shiftKey) {
9180
+ onCloseGesture?.();
9181
+ return;
9182
+ }
9183
+ if (mod) {
9184
+ snap.fit();
9185
+ return;
9186
+ }
9187
+ if (e.altKey) {
9188
+ snap.dock();
9189
+ return;
9190
+ }
9191
+ if (e.shiftKey) {
9192
+ snap.expand();
9193
+ return;
9194
+ }
9195
+ if (state.sizingMode === "docked") {
9196
+ snap.fit();
9197
+ return;
9198
+ }
9199
+ onClickHandle();
9200
+ }, [onClickHandle, snap, onCloseGesture, state.sizingMode]);
9201
+ const { handleTransitionEnd } = useSheetLifecycle({
9202
+ isOpen,
9203
+ entryAnimation: entryAnimation !== "never",
9204
+ visuallyOpen: animState.visuallyOpen,
9205
+ variant,
9206
+ container,
9207
+ panelRef: panelRefObj,
9208
+ panelEl,
9209
+ dispatch: animDispatch,
9210
+ onCloseComplete,
9211
+ fitHeightReady,
9212
+ });
9213
+ const showOverlay = variant === "modal" && state.sizingMode !== "docked" && animState.visuallyOpen;
9214
+ const fitHeightMeasured = state.sizingMode === "fit" && fitHeight > 0;
9215
+ const snapHeightCss = state.sizingMode === "docked"
9216
+ ? `${state.effectiveDockedHeight}px`
9217
+ : fitHeightMeasured
9218
+ ? `${cappedFitHeight}px`
9219
+ : getSnapLevelCssHeight(state.level, state.totalLevels, dockedContent !== undefined, state.effectiveDockedHeight);
9220
+ const fitMaxHeight = state.sizingMode === "fit" ? `calc(100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px)` : undefined;
9221
+ if (!animState.shouldRender)
9222
+ return null;
9223
+ if (!container) {
9224
+ return null;
9225
+ }
9226
+ const gestureHandlers = effectiveSnapping
9227
+ ? {
9228
+ onPointerDown: gestures.onPointerDown,
9229
+ onPointerMove: gestures.onPointerMove,
9230
+ onPointerUp: gestures.onPointerUp,
9231
+ onLostPointerCapture: gestures.onLostPointerCapture,
9232
+ }
9233
+ : {};
9234
+ const panel = (jsxs(Card, { ...(shouldTrapFocus === true ? { "aria-modal": true, role: "dialog" } : {}), ...(floatingUi?.getFloatingProps() ?? {}), className: cvaSheetPanel({ anchor, variant, className }), "data-testid": dataTestId, onTransitionEnd: handleTransitionEnd, ref: panelRefWithFloating, style: getSheetPanelStyle({
9235
+ autoHeight: state.sizingMode === "fit" && !fitHeightMeasured,
9236
+ closePhase: animState.closePhase,
9237
+ isDragging: gestures.isDragging,
9238
+ isOpen: animState.visuallyOpen,
9239
+ maxHeight: fitMaxHeight,
9240
+ snapHeight: snapHeightCss,
9241
+ suppressTransition: skipEntryAnimation && animState.visuallyOpen,
9242
+ variant,
9243
+ }), children: [resizable ? (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 })] }));
9244
+ return (jsx(Portal, { root: container, children: jsxs("div", { className: cvaSheetContainer({
9245
+ docked: state.sizingMode === "docked",
9246
+ }), "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)] }) }));
9247
+ };
9248
+
9249
+ /** Resolves DismissOptions with defaults (all true when omitted). */
9250
+ const DEFAULT_DISMISS_OPTIONS = {
9251
+ escapeKey: true,
9252
+ outsidePress: true,
9253
+ gesture: true,
9254
+ };
9255
+ /** Merges caller-supplied DismissOptions with defaults, returning a fully-resolved options object. */
9256
+ function resolveDismissOptions(dismiss) {
9257
+ return {
9258
+ escapeKey: dismiss?.escapeKey ?? DEFAULT_DISMISS_OPTIONS.escapeKey,
9259
+ outsidePress: dismiss?.outsidePress ?? DEFAULT_DISMISS_OPTIONS.outsidePress,
9260
+ gesture: dismiss?.gesture ?? DEFAULT_DISMISS_OPTIONS.gesture,
9261
+ };
9262
+ }
9263
+
9264
+ const INITIAL_SNAP_STATE = {
9265
+ currentLevel: 0,
9266
+ sizingMode: "level",
9267
+ initialSizeApplied: false,
9268
+ prevIsOpen: false,
9269
+ totalLevels: 0,
9270
+ dockingEnabled: false,
9271
+ levelHeights: [],
9272
+ effectiveDockedHeight: 0,
9273
+ fitHeight: 0,
9274
+ };
9275
+ const findLevelByHeight = (heightPx, levelHeights) => {
9276
+ let closestIndex = 0;
9277
+ let closestDistance = Infinity;
9278
+ for (let i = 0; i < levelHeights.length; i++) {
9279
+ const h = levelHeights[i];
9280
+ if (h === undefined)
9281
+ continue;
9282
+ const distance = Math.abs(h - heightPx);
9283
+ if (distance < closestDistance) {
9284
+ closestDistance = distance;
9285
+ closestIndex = i;
9286
+ }
9287
+ }
9288
+ return closestIndex;
9289
+ };
9290
+ const withSnapSync = (state, currentLevel, sizingMode) => ({
9291
+ ...state,
9292
+ currentLevel,
9293
+ sizingMode,
9294
+ });
9295
+ /** Reducer managing snap-level positioning and measurement-derived state. */
9296
+ const snapReducer = (state, action) => {
9297
+ switch (action.type) {
9298
+ case "SYNC_OPEN": {
9299
+ if (action.isOpen === state.prevIsOpen)
9300
+ return state;
9301
+ if (action.isOpen) {
9302
+ return {
9303
+ ...state,
9304
+ prevIsOpen: true,
9305
+ currentLevel: 0,
9306
+ sizingMode: "level",
9307
+ initialSizeApplied: false,
9308
+ };
9309
+ }
9310
+ return {
9311
+ ...state,
9312
+ prevIsOpen: false,
9313
+ initialSizeApplied: false,
9314
+ };
9315
+ }
9316
+ case "SYNC_MEASUREMENTS": {
9317
+ return {
9318
+ ...state,
9319
+ totalLevels: action.levelHeights.length,
9320
+ dockingEnabled: action.measurements.dockingEnabled,
9321
+ levelHeights: action.levelHeights,
9322
+ effectiveDockedHeight: action.measurements.dockedHeight,
9323
+ fitHeight: action.measurements.fitHeight,
9324
+ };
9325
+ }
9326
+ case "APPLY_INITIAL_SIZE": {
9327
+ if (state.initialSizeApplied)
9328
+ return state;
9329
+ if (action.defaultSize === "fit") {
9330
+ return { ...state, initialSizeApplied: true, sizingMode: "fit" };
9331
+ }
9332
+ if (action.defaultSize === "collapsed") {
9333
+ return withSnapSync({ ...state, initialSizeApplied: true }, 0, "level");
9334
+ }
9335
+ if (action.defaultSize === "expanded") {
9336
+ return withSnapSync({ ...state, initialSizeApplied: true }, state.totalLevels - 1, "level");
9337
+ }
9338
+ if (state.dockingEnabled) {
9339
+ return withSnapSync({ ...state, initialSizeApplied: true }, 0, "docked");
9340
+ }
9341
+ return state;
9342
+ }
9343
+ case "SNAP_UP": {
9344
+ if (state.sizingMode === "docked") {
9345
+ return withSnapSync(state, 0, "level");
9346
+ }
9347
+ const nextUp = Math.min(state.currentLevel + 1, state.totalLevels - 1);
9348
+ return nextUp === state.currentLevel && state.sizingMode !== "fit" ? state : withSnapSync(state, nextUp, "level");
9349
+ }
9350
+ case "SNAP_DOWN": {
9351
+ if (state.sizingMode === "docked")
9352
+ return state;
9353
+ if (state.currentLevel === 0 && state.dockingEnabled) {
9354
+ return withSnapSync(state, state.currentLevel, "docked");
9355
+ }
9356
+ const nextDown = Math.max(state.currentLevel - 1, 0);
9357
+ return nextDown === state.currentLevel && state.sizingMode !== "fit"
9358
+ ? state
9359
+ : withSnapSync(state, nextDown, "level");
9360
+ }
9361
+ case "EXPAND":
9362
+ return withSnapSync(state, state.totalLevels - 1, "level");
9363
+ case "COLLAPSE":
9364
+ return withSnapSync(state, 0, "level");
9365
+ case "DOCK":
9366
+ return state.dockingEnabled ? withSnapSync(state, state.currentLevel, "docked") : state;
9367
+ case "FIT":
9368
+ return { ...state, sizingMode: "fit" };
9369
+ case "GESTURE_SNAP": {
9370
+ if (state.dockingEnabled && Math.abs(action.heightPx - state.effectiveDockedHeight) < 1) {
9371
+ return withSnapSync(state, state.currentLevel, "docked");
9372
+ }
9373
+ if (state.sizingMode === "fit" && !action.useLevelSnap) {
9374
+ if (state.fitHeight > 0 && Math.abs(action.heightPx - state.fitHeight) < 1) {
9375
+ return state;
9376
+ }
9377
+ const fitLevel = findLevelByHeight(action.heightPx, state.levelHeights);
9378
+ return withSnapSync(state, fitLevel, "level");
9379
+ }
9380
+ const level = findLevelByHeight(action.heightPx, state.levelHeights);
9381
+ return withSnapSync(state, level, "level");
9382
+ }
9383
+ case "CYCLE_SNAP": {
9384
+ if (state.sizingMode === "fit")
9385
+ return state;
9386
+ if (state.sizingMode === "docked")
9387
+ return withSnapSync(state, 0, "level");
9388
+ const nextCycle = state.currentLevel + 1;
9389
+ if (nextCycle >= state.totalLevels) {
9390
+ return state.dockingEnabled
9391
+ ? withSnapSync(state, state.currentLevel, "docked")
9392
+ : withSnapSync(state, 0, "level");
9393
+ }
9394
+ return withSnapSync(state, nextCycle, "level");
9395
+ }
9396
+ default:
9397
+ return state;
9398
+ }
9399
+ };
9400
+
9401
+ const buildState = (snapState, dockedHeight) => ({
9402
+ sizingMode: snapState.sizingMode,
9403
+ level: snapState.currentLevel,
9404
+ totalLevels: snapState.totalLevels,
9405
+ isExpanded: snapState.sizingMode === "level" &&
9406
+ snapState.totalLevels > 0 &&
9407
+ snapState.currentLevel === snapState.totalLevels - 1,
9408
+ isCollapsed: snapState.sizingMode === "level" && snapState.currentLevel === 0,
9409
+ dockedHeight,
9410
+ levelHeights: snapState.levelHeights,
9411
+ effectiveDockedHeight: snapState.effectiveDockedHeight,
9412
+ });
9413
+ /**
9414
+ * Composable hook managing snap-level state for the Sheet component.
9415
+ *
9416
+ * Owns the snap reducer, processes measurements from Sheet via `onGeometryChange`,
9417
+ * and provides stable navigation methods. Used by `useSheet` internally and
9418
+ * can be used directly by Modal for independent snap management.
9419
+ */
9420
+ const useSheetSnap = ({ defaultSize, isOpen }) => {
9421
+ const [state, dispatch] = useReducer(snapReducer, INITIAL_SNAP_STATE);
9422
+ if (isOpen !== state.prevIsOpen) {
9423
+ dispatch({ type: "SYNC_OPEN", isOpen });
9424
+ }
9425
+ if (isOpen && !state.initialSizeApplied && (state.totalLevels > 0 || defaultSize === "fit")) {
9426
+ dispatch({ type: "APPLY_INITIAL_SIZE", defaultSize });
9427
+ }
9428
+ const dockedHeightForSize = state.dockingEnabled ? state.effectiveDockedHeight : 0;
9429
+ const sheetState = useMemo(() => buildState(state, dockedHeightForSize), [state, dockedHeightForSize]);
9430
+ const onGeometryChange = useCallback((m, dockingEnabled) => {
9431
+ const effectiveDockedHeight = m.dockedHeight > 0 ? m.dockedHeight : DOCKED_HEIGHT_PX;
9432
+ const levelHeights = computeSnapLevelHeights(m.containerHeight, dockingEnabled, effectiveDockedHeight);
9433
+ dispatch({
9434
+ type: "SYNC_MEASUREMENTS",
9435
+ measurements: {
9436
+ fitHeight: m.fitHeight,
9437
+ dockedHeight: effectiveDockedHeight,
9438
+ dockingEnabled,
9439
+ },
9440
+ levelHeights,
9441
+ });
9442
+ }, []);
9443
+ const onSnap = useCallback((heightPx, useLevelSnap) => {
9444
+ dispatch({ type: "GESTURE_SNAP", heightPx, useLevelSnap });
9445
+ }, []);
9446
+ const onClickHandle = useCallback(() => {
9447
+ dispatch({ type: "CYCLE_SNAP" });
9448
+ }, []);
9449
+ const snapUp = useCallback(() => {
9450
+ dispatch({ type: "SNAP_UP" });
9451
+ }, []);
9452
+ const snapDown = useCallback(() => {
9453
+ dispatch({ type: "SNAP_DOWN" });
9454
+ }, []);
9455
+ const expand = useCallback(() => {
9456
+ dispatch({ type: "EXPAND" });
9457
+ }, []);
9458
+ const collapse = useCallback(() => {
9459
+ dispatch({ type: "COLLAPSE" });
9460
+ }, []);
9461
+ const dock = useCallback(() => {
9462
+ dispatch({ type: "DOCK" });
9463
+ }, []);
9464
+ const fit = useCallback(() => {
9465
+ dispatch({ type: "FIT" });
9466
+ }, []);
9467
+ const snap = useMemo(() => ({ snapUp, snapDown, expand, collapse, dock, fit }), [snapUp, snapDown, expand, collapse, dock, fit]);
9468
+ return useMemo(() => ({ state: sheetState, snap, onGeometryChange, onSnap, onClickHandle }), [sheetState, snap, onGeometryChange, onSnap, onClickHandle]);
9469
+ };
9470
+
9471
+ /**
9472
+ * Hook for managing Sheet open/close state and directional snap navigation.
9473
+ *
9474
+ * Consumers navigate via `snap.snapUp()` / `snap.snapDown()` /
9475
+ * `snap.expand()` / `snap.collapse()` / `snap.dock()` instead of
9476
+ * naming specific snap points. The snap state is managed by the composable
9477
+ * `useSheetSnap` hook internally.
9478
+ *
9479
+ * Owns all dismiss handling (ESC, outside-press, gesture) via Floating UI's
9480
+ * `useDismiss`. Each dismiss path goes through `onOpenChange` →
9481
+ * `requestClose` with the correct `CloseReason`, then through
9482
+ * `onBeforeClose` guard, then `handleClose`. The public `close()` is a
9483
+ * no-arg wrapper that tags the reason as `"programmatic"`.
9484
+ *
9485
+ * Outside-press dismiss is only active when `variant="modal"` — the only
9486
+ * variant that renders a backdrop overlay. For `"default"` and `"floating"`
9487
+ * variants, clicks outside the panel do not close the sheet.
9488
+ *
9489
+ * Returns a `floatingUi` object (context, refs, getFloatingProps) that the
9490
+ * Sheet component uses for focus management and dismiss interaction binding,
9491
+ * mirroring `useModal`'s architecture.
9492
+ *
9493
+ * Supports controlled (isOpen) and uncontrolled (defaultOpen) modes, stores
9494
+ * external callbacks in refs for stability.
9495
+ */
9496
+ const useSheet = (props) => {
9497
+ const { isOpen: controlledIsOpen, defaultOpen, onClose, onOpen, onOpenChange, onBeforeClose, dismiss: dismissProp, variant = "default", defaultSize = "fit", onStateChange, onGeometryChange: onGeometryChangeProp, } = props ?? {};
9498
+ const dismiss = useMemo(() => resolveDismissOptions(dismissProp), [dismissProp]);
9499
+ const [internalIsOpen, setIsOpen] = useState(defaultOpen ?? false);
9500
+ const isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen : internalIsOpen;
9501
+ const ref = useRef(null);
9502
+ const isPendingCloseRef = useRef(false);
9503
+ const onCloseRef = useRef(onClose);
9504
+ const onOpenRef = useRef(onOpen);
9505
+ const onOpenChangeRef = useRef(onOpenChange);
9506
+ const onBeforeCloseRef = useRef(onBeforeClose);
9507
+ const onStateChangeRef = useRef(onStateChange);
9508
+ const onGeometryChangeRef = useRef(onGeometryChangeProp);
9509
+ useLayoutEffect(() => {
9510
+ onCloseRef.current = onClose;
9511
+ onOpenRef.current = onOpen;
9512
+ onOpenChangeRef.current = onOpenChange;
9513
+ onBeforeCloseRef.current = onBeforeClose;
9514
+ onStateChangeRef.current = onStateChange;
9515
+ onGeometryChangeRef.current = onGeometryChangeProp;
9516
+ });
9517
+ const { state, snap, onGeometryChange: snapOnGeometryChange, onSnap, onClickHandle, } = useSheetSnap({ defaultSize, isOpen });
9518
+ useEffect(() => {
9519
+ onStateChangeRef.current?.(state);
9520
+ }, [state]);
9521
+ const onGeometryChange = useCallback((geometry, dockingEnabled) => {
9522
+ snapOnGeometryChange(geometry, dockingEnabled);
9523
+ onGeometryChangeRef.current?.(geometry);
9524
+ }, [snapOnGeometryChange]);
9525
+ const handleClose = useCallback((event, reason) => {
9526
+ setIsOpen(false);
9527
+ onCloseRef.current?.(event, reason);
9528
+ onOpenChangeRef.current?.(false, event, reason);
9529
+ }, []);
9530
+ const requestClose = useCallback((event, reason) => {
9531
+ if (onBeforeCloseRef.current) {
9532
+ if (isPendingCloseRef.current) {
9533
+ return;
9534
+ }
9535
+ isPendingCloseRef.current = true;
9536
+ void Promise.resolve(onBeforeCloseRef.current(event, reason))
9537
+ .then(shouldClose => {
9538
+ if (shouldClose) {
9539
+ handleClose(event, reason);
9540
+ }
9541
+ })
9542
+ .finally(() => {
9543
+ isPendingCloseRef.current = false;
9544
+ });
9545
+ return;
9546
+ }
9547
+ handleClose(event, reason);
9548
+ }, [handleClose]);
9549
+ const close = useCallback(() => requestClose(undefined, "programmatic"), [requestClose]);
9550
+ const open = useCallback(() => {
9551
+ onOpenRef.current?.();
9552
+ onOpenChangeRef.current?.(true);
9553
+ setIsOpen(true);
9554
+ }, []);
9555
+ const { context: floatingContext, refs: floatingRefs } = useFloating({
9556
+ open: isOpen,
9557
+ onOpenChange: (newIsOpen, event, reason) => {
9558
+ if (newIsOpen)
9559
+ return;
9560
+ const closeReason = reason === "escape-key" || reason === "outside-press" ? reason : undefined;
9561
+ requestClose(event, closeReason);
9562
+ },
9563
+ });
9564
+ const dismissInteraction = useDismiss(floatingContext, {
9565
+ escapeKey: dismiss.escapeKey,
9566
+ outsidePress: variant === "modal" && dismiss.outsidePress,
9567
+ });
9568
+ const { getFloatingProps } = useInteractions([dismissInteraction]);
9569
+ const floatingUi = useMemo(() => ({ context: floatingContext, refs: floatingRefs, getFloatingProps }), [floatingContext, floatingRefs, getFloatingProps]);
9570
+ const onCloseGesture = useMemo(() => (dismiss.gesture ? () => requestClose(undefined, "gesture") : undefined), [dismiss.gesture, requestClose]);
9571
+ return useMemo(() => ({
9572
+ isOpen,
9573
+ ref,
9574
+ state,
9575
+ variant,
9576
+ open,
9577
+ close,
9578
+ snap,
9579
+ floatingUi,
9580
+ onGeometryChange,
9581
+ onSnap,
9582
+ onClickHandle,
9583
+ onCloseGesture,
9584
+ }), [isOpen, state, variant, open, close, snap, floatingUi, onGeometryChange, onSnap, onClickHandle, onCloseGesture]);
9585
+ };
9586
+
9587
+ const cvaSidebar = cvaMerge(["apply", "grid", "grid-cols-fr-min", "items-center"]);
9588
+ const cvaSidebarChildContainer = cvaMerge(["apply", "flex", "overflow-hidden", "gap-2", "p-1", "max-w-full"], {
9589
+ variants: {
9590
+ breakpoint: {
9591
+ xs: ["xs:overflow-visible", "xs:p-0", "xs:grid", "xs:grid-cols-1", "xs:w-full"],
9592
+ sm: ["sm:overflow-visible", "sm:p-0", "sm:grid", "sm:grid-cols-1", "sm:w-full"],
9593
+ md: ["md:overflow-visible", "md:p-0", "md:grid", "md:grid-cols-1", "md:w-full"],
9594
+ lg: ["lg:overflow-visible", "lg:p-0", "lg:grid", "lg:grid-cols-1", "lg:w-full"],
9595
+ xl: ["xl:overflow-visible", "xl:p-0", "xl:grid", "xl:grid-cols-1", "xl:w-full"],
9596
+ "2xl": ["2xl:overflow-visible", "2xl:p-0", "2xl:grid", "2xl:grid-cols-1", "2xl:w-full"],
9597
+ "3xl": ["3xl:overflow-visible", "3xl:p-0", "3xl:grid", "3xl:grid-cols-1", "3xl:w-full"],
9598
+ },
9599
+ },
9600
+ });
9601
+
9602
+ /**
9603
+ * A hook used to detect what children are overflowing a container.
9604
+ *
9605
+ * @param {OverflowItemsOptions} root0 The options for the hook.
9606
+ * @param {number | Array<number>} root0.threshold The threshold for the intersection observer.
9607
+ * @returns {object} The overflow items and the ref to the container.
9608
+ */
9609
+ const useOverflowItems = ({ threshold = 1, childUniqueIdentifierAttribute = "id", children, }) => {
9610
+ const [itemOverflowMap, setItemOverflowMap] = useState({});
9611
+ const overflowContainerRef = useRef(null);
9612
+ const observerRef = useRef(null);
9613
+ const handleIntersection = useCallback(entries => {
9614
+ const updatedEntries = {};
9615
+ entries.forEach(entry => {
9616
+ // @ts-expect-error - suppressImplicitAnyIndexErrors
9617
+ const targetElementId = entry.target[childUniqueIdentifierAttribute];
9618
+ if (targetElementId !== null && targetElementId !== undefined && targetElementId !== "") {
9619
+ updatedEntries[targetElementId] = entry.isIntersecting ? false : true;
9620
+ }
9621
+ });
9622
+ setItemOverflowMap(prev => ({ ...prev, ...updatedEntries }));
9623
+ }, [childUniqueIdentifierAttribute]);
9624
+ const observe = useCallback(() => {
9625
+ const node = overflowContainerRef.current;
9626
+ if (node) {
9627
+ const root = overflowContainerRef.current;
9628
+ const options = { root, threshold };
9629
+ const observer = new IntersectionObserver(handleIntersection, options);
9630
+ Array.from(node.children).forEach(child => {
9631
+ observer.observe(child);
9632
+ });
9633
+ observer.observe(node);
9634
+ observerRef.current = observer;
9635
+ }
9636
+ }, [handleIntersection, threshold]);
9637
+ const unobserve = useCallback(() => {
9638
+ const currentObserver = observerRef.current;
9639
+ currentObserver?.disconnect();
9640
+ observerRef.current = null;
9641
+ }, []);
9642
+ const initializeObserver = useCallback(() => {
9643
+ unobserve();
9644
+ observe();
9645
+ }, [observe, unobserve]);
9646
+ useEffect(() => {
9647
+ initializeObserver();
9648
+ return () => {
9649
+ unobserve();
9650
+ };
9651
+ }, [initializeObserver, unobserve, children]);
9652
+ return useMemo(() => ({ overflowContainerRef, itemOverflowMap }), [overflowContainerRef, itemOverflowMap]);
9653
+ };
9654
+
9655
+ /**
9656
+ * Sidebar renders a responsive horizontal/vertical navigation bar that automatically collapses overflowing items into a MoreMenu.
9657
+ * It is rendered horizontally until a given breakpoint, then stacks vertically.
9658
+ *
9659
+ * **Important:** The Sidebar is just a layout wrapper. You are responsible for styling the children.
9660
+ * For the overflow functionality, use `min-w-[*]` or `flex-shrink-0` on child elements.
9661
+ *
9662
+ * When testing, add `setupIntersectionObserver();` to your `jest.setup.ts` file.
9663
+ *
9664
+ * ### When to use
9665
+ * Use Sidebar for secondary in-page navigation (e.g., switching between views within a page). Works well with `Tabs` for page-level navigation.
9666
+ *
9667
+ * ### When not to use
9668
+ * Do not use Sidebar for primary app navigation (the main menu). For top-level navigation, use the app shell navigation.
9669
+ *
9670
+ * @example Responsive sidebar with navigation items
9671
+ * ```tsx
9672
+ * import { Sidebar, Button } from "@trackunit/react-components";
9673
+ *
9674
+ * const NavigationSidebar = () => (
9675
+ * <Sidebar breakpoint="lg">
9676
+ * <Button id="overview" variant="ghost-neutral" className="min-w-[100px]">
9677
+ * Overview
9678
+ * </Button>
9679
+ * <Button id="assets" variant="ghost-neutral" className="min-w-[100px]">
9680
+ * Assets
9681
+ * </Button>
9682
+ * <Button id="reports" variant="ghost-neutral" className="min-w-[100px]">
9683
+ * Reports
9684
+ * </Button>
9685
+ * <Button id="settings" variant="ghost-neutral" className="min-w-[100px]">
9686
+ * Settings
9687
+ * </Button>
9688
+ * </Sidebar>
9689
+ * );
9690
+ * ```
9691
+ * @param {SidebarProps} props - The props for the Sidebar component
9692
+ * @returns {ReactElement} Sidebar component
9693
+ */
9694
+ const Sidebar = ({ childContainerClassName, children, breakpoint = "lg", className, "data-testid": dataTestId = "sidebar", moreMenuProps, menuListProps, overflowThreshold, ref, }) => {
9695
+ const { overflowContainerRef, itemOverflowMap } = useOverflowItems({
9696
+ children,
9697
+ childUniqueIdentifierAttribute: "id",
9698
+ threshold: overflowThreshold ?? 0.8,
9699
+ });
9700
+ const overflowItemCount = objectValues(itemOverflowMap).filter(isOverflowing => isOverflowing).length;
9701
+ const itemVisibilityClassName = (id) => {
9702
+ if (itemOverflowMap[id] === true) {
9703
+ return "invisible";
9704
+ }
9705
+ return "visible";
9706
+ };
9707
+ return (jsxs("div", { className: cvaSidebar({ className }), "data-testid": dataTestId, ref: ref, children: [jsx("div", { className: cvaSidebarChildContainer({ breakpoint, className: childContainerClassName }), "data-testid": `${dataTestId}-child-container`, ref: overflowContainerRef, children: Children.map(children, child => {
9708
+ return cloneElement(child, {
9709
+ className: twMerge(child.props.className, itemVisibilityClassName(child.props.id)),
9710
+ });
9711
+ }) }), overflowItemCount > 0 ? (jsx(MoreMenu, { iconButtonProps: {
9712
+ variant: "ghost-neutral",
9713
+ }, ...moreMenuProps, className: moreMenuProps?.className, "data-testid": `${dataTestId}-more-menu`, children: close => (jsx(MenuList, { ...menuListProps, "data-testid": dataTestId, children: Children.map(children, child => {
9714
+ return itemOverflowMap[child.props.id] === true
9715
+ ? cloneElement(child, {
9716
+ onClick: e => {
9717
+ child.props.onClick?.(e);
9718
+ close();
9719
+ },
9720
+ className: "w-full",
9721
+ })
9722
+ : null;
9723
+ }) })) })) : null] }));
9724
+ };
9725
+
9726
+ const cvaTabsRoot = cvaMerge([]);
9727
+ const cvaTabList = cvaMerge([
9728
+ "flex",
9729
+ "flex-row",
9730
+ "border-b",
9731
+ "border-b-neutral-200",
9732
+ "overflow-auto",
9733
+ "no-scrollbar",
9734
+ ]);
9735
+ const cvaTabContent = cvaMerge([]);
9736
+ const cvaTab = cvaMerge([
9737
+ "flex",
9738
+ "items-center",
9739
+ "justify-center",
9740
+ "gap-2",
9741
+ "text-sm",
9742
+ "px-6",
9743
+ "py-2",
9744
+ "cursor-pointer",
9745
+ "transition",
9746
+ "duration-200",
9747
+ "ease-in-out",
9748
+ "whitespace-nowrap",
9749
+ "border-b-2",
9750
+ "border-b-transparent",
9751
+ "hover:border-b-transparent",
9752
+ "data-[state=active]:border-b-primary-600",
9753
+ "data-[state=active]:font-semibold",
9754
+ "data-[state=active]:text-neutral-900",
9755
+ "data-[state=inactive]:font-medium",
9756
+ "data-[state=inactive]:text-neutral-700",
9757
+ "data-[state=inactive]:hover:text-neutral-900",
9758
+ "disabled:cursor-not-allowed",
9759
+ "data-[state=active]disabled:text-neutral-400",
9760
+ "data-[state=inactive]:disabled:text-neutral-400",
9761
+ ], {
9762
+ variants: {
9763
+ isFullWidth: {
9764
+ true: "w-full",
9765
+ false: "",
9766
+ },
9767
+ },
9768
+ defaultVariants: {
9769
+ isFullWidth: false,
9770
+ },
9771
+ });
9772
+
9773
+ /**
9774
+ * Tab is an individual tab trigger within a TabList.
9775
+ * Each Tab requires a unique `value` prop that corresponds to a TabContent with the same value.
9776
+ * Supports optional icons, suffixes (e.g., badges), and rendering as a custom child element via `asChild`.
9777
+ *
9778
+ * ### When to use
9779
+ * Use Tab inside a `TabList` to create clickable tab triggers. Each Tab maps to a `TabContent` panel.
9780
+ *
9781
+ * ### When not to use
9782
+ * Do not use Tab outside of a `TabList`/`Tabs` context. For standalone buttons, use `Button`.
9783
+ *
9784
+ * @example Tab with icon and badge suffix
9785
+ * ```tsx
9786
+ * import { Tabs, TabList, Tab, TabContent, Badge } from "@trackunit/react-components";
9787
+ *
9788
+ * const TabsWithBadge = () => (
9789
+ * <Tabs defaultValue="alerts">
9790
+ * <TabList>
9791
+ * <Tab value="alerts" iconName="Bell" suffix={<Badge count={3} color="danger" />}>
9792
+ * Alerts
9793
+ * </Tab>
9794
+ * <Tab value="history" iconName="Clock">History</Tab>
9795
+ * </TabList>
9796
+ * <TabContent value="alerts">Active alerts</TabContent>
9797
+ * <TabContent value="history">Event history</TabContent>
9798
+ * </Tabs>
9799
+ * );
9800
+ * ```
9801
+ * @param {TabProps} props - The props for the Tab component
9802
+ * @returns {ReactElement} Tab component
9803
+ */
9804
+ const Tab = ({ value, isFullWidth = false, iconName = undefined, "data-testid": dataTestId, className, children, suffix, asChild = false, appendTabStylesToChildIfAsChild = true, ref, ...rest }) => {
9805
+ const renderContent = () => (jsxs(Fragment, { children: [iconName !== undefined ? jsx(Icon, { name: iconName, size: "small" }) : null, isValidElement(children) ? children.props.children : children, suffix] }));
9806
+ const commonProps = {
9807
+ className: appendTabStylesToChildIfAsChild ? cvaTab({ className, isFullWidth }) : className,
9808
+ ...rest,
9809
+ };
9810
+ return (jsx(Trigger, { asChild: true, ref: ref, value: value, children: asChild && typeof children !== "string" ? (cloneElement(children, {
9811
+ ...commonProps,
9812
+ children: renderContent(),
9813
+ })) : (jsx("button", { ...commonProps, "data-testid": dataTestId, children: renderContent() })) }));
9814
+ };
9815
+
9816
+ /**
9817
+ * TabContent is the content panel displayed when its corresponding Tab is active.
9818
+ * Each TabContent must have a `value` prop that matches the `value` of its corresponding Tab trigger.
9819
+ * TabContent is only rendered when its tab is selected (unless `forceMount` is set).
9820
+ *
9821
+ * ### When to use
9822
+ * Use TabContent inside a `Tabs` component, one for each `Tab` trigger.
9823
+ *
9824
+ * ### When not to use
9825
+ * Do not use TabContent outside of a `Tabs` context. For conditionally shown content, use standard conditional rendering.
7786
9826
  *
7787
9827
  * @example Tabs with styled content panels
7788
9828
  * ```tsx
@@ -8276,8 +10316,11 @@ const useCustomEncoding = () => {
8276
10316
  // If it's already a string, use it directly; otherwise stringify the object
8277
10317
  const json = typeof input === "string" ? input : JSON.stringify(input);
8278
10318
  const textInput = new TextEncoder().encode(json);
8279
- // Use fflate for synchronous gzip compression
8280
- const compressed = gzipSync(textInput);
10319
+ // Use fflate for synchronous gzip compression.
10320
+ // mtime: 0 ensures deterministic output — without it the gzip header
10321
+ // includes the current timestamp, making the same input produce a
10322
+ // different encoded string on every call.
10323
+ const compressed = gzipSync(textInput, { mtime: 0 });
8281
10324
  return b64urlEncode(compressed);
8282
10325
  }
8283
10326
  catch (_) {
@@ -8329,6 +10372,36 @@ const useCustomEncoding = () => {
8329
10372
  return useMemo(() => ({ encode, decode }), [encode, decode]);
8330
10373
  };
8331
10374
 
10375
+ /**
10376
+ * Internal envelope used to tag superjson-serialized data in web storage.
10377
+ *
10378
+ * `__serializer` is a **reserved internal key** — consumer state objects must
10379
+ * not include it as a top-level key. The double-underscore prefix is a
10380
+ * deliberate signal that this is a private implementation detail.
10381
+ *
10382
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
10383
+ * detected in the value being written.
10384
+ */
10385
+ const taggedSuperjsonEnvelopeSchema = z.object({
10386
+ __serializer: z.literal("superjson"),
10387
+ json: z.custom(),
10388
+ meta: z.custom().optional(),
10389
+ });
10390
+ const storageSerializer = {
10391
+ serialize: (value) => {
10392
+ const serialized = superjson.serialize(value);
10393
+ return JSON.stringify({ __serializer: "superjson", ...serialized });
10394
+ },
10395
+ deserialize: (value) => {
10396
+ const parsed = JSON.parse(value);
10397
+ const result = taggedSuperjsonEnvelopeSchema.safeParse(parsed);
10398
+ if (result.success) {
10399
+ return superjson.deserialize({ json: result.data.json, meta: result.data.meta });
10400
+ }
10401
+ return parsed;
10402
+ },
10403
+ };
10404
+
8332
10405
  /**
8333
10406
  * Runs a sequential migration pipeline on the provided data, applying
8334
10407
  * each migration whose version is in the range (fromVersion, toVersion].
@@ -8434,36 +10507,6 @@ const salvageState = (schema, rawData, defaultState) => {
8434
10507
  }
8435
10508
  };
8436
10509
 
8437
- /**
8438
- * Internal envelope used to tag superjson-serialized data in web storage.
8439
- *
8440
- * `__serializer` is a **reserved internal key** — consumer state objects must
8441
- * not include it as a top-level key. The double-underscore prefix is a
8442
- * deliberate signal that this is a private implementation detail.
8443
- *
8444
- * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
8445
- * detected in the value being written.
8446
- */
8447
- const taggedSuperjsonEnvelopeSchema = z.object({
8448
- __serializer: z.literal("superjson"),
8449
- json: z.custom(),
8450
- meta: z.custom().optional(),
8451
- });
8452
- const storageSerializer = {
8453
- serialize: (value) => {
8454
- const serialized = superjson.serialize(value);
8455
- return JSON.stringify({ __serializer: "superjson", ...serialized });
8456
- },
8457
- deserialize: (value) => {
8458
- const parsed = JSON.parse(value);
8459
- const result = taggedSuperjsonEnvelopeSchema.safeParse(parsed);
8460
- if (result.success) {
8461
- return superjson.deserialize({ json: result.data.json, meta: result.data.meta });
8462
- }
8463
- return parsed;
8464
- },
8465
- };
8466
-
8467
10510
  /**
8468
10511
  * Internal envelope that pairs stored data with a schema version number.
8469
10512
  *
@@ -8891,53 +10934,249 @@ const useWebStorageReducer = (storage, { key, defaultState, schema, migration, r
8891
10934
  };
8892
10935
 
8893
10936
  /**
8894
- * Works like useReducer, but persists to localStorage with Zod schema validation
8895
- * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
10937
+ * Works like useReducer, but persists to localStorage with Zod schema validation
10938
+ * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
10939
+ *
10940
+ * @template TState - The type of the stored state.
10941
+ * @template TAction - The type of the reducer action.
10942
+ * @param options - Key, defaultState, schema, reducer, and optional callbacks.
10943
+ * @param options.key - The storage key.
10944
+ * @param options.defaultState - Fallback value when no stored data exists.
10945
+ * @param options.schema - Zod schema for validation.
10946
+ * @param options.reducer - The reducer function.
10947
+ * @param options.onValidationFailed - Optional error callback.
10948
+ * @param options.onValidationSuccessful - Optional success callback.
10949
+ * @returns {Array} A tuple of [state, dispatch].
10950
+ */
10951
+ const useLocalStorageReducer = (options) => useWebStorageReducer(globalThis.localStorage, options);
10952
+
10953
+ /**
10954
+ * Works like useState, but persists to sessionStorage with Zod schema validation
10955
+ * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
10956
+ *
10957
+ * @template TState - The type of the stored state.
10958
+ * @param options - Key, defaultState, schema, and optional callbacks.
10959
+ * @param options.key - The storage key.
10960
+ * @param options.defaultState - Fallback value when no stored data exists.
10961
+ * @param options.schema - Zod schema for validation.
10962
+ * @param options.onValidationFailed - Optional error callback.
10963
+ * @param options.onValidationSuccessful - Optional success callback.
10964
+ * @returns {Array} A tuple of [state, setState, reset].
10965
+ */
10966
+ const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage, options);
10967
+
10968
+ /**
10969
+ * Works like useReducer, but persists to sessionStorage with Zod schema validation
10970
+ * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
10971
+ *
10972
+ * @template TState - The type of the stored state.
10973
+ * @template TAction - The type of the reducer action.
10974
+ * @param options - Key, defaultState, schema, reducer, and optional callbacks.
10975
+ * @param options.key - The storage key.
10976
+ * @param options.defaultState - Fallback value when no stored data exists.
10977
+ * @param options.schema - Zod schema for validation.
10978
+ * @param options.reducer - The reducer function.
10979
+ * @param options.onValidationFailed - Optional error callback.
10980
+ * @param options.onValidationSuccessful - Optional success callback.
10981
+ * @returns {Array} A tuple of [state, dispatch].
10982
+ */
10983
+ const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
10984
+
10985
+ const MAX_URL_LENGTH = 5000;
10986
+ /**
10987
+ * Syncs an encoded string value with a URL search parameter via Tanstack Router.
10988
+ *
10989
+ * Provides a write function that updates the URL through the application
10990
+ * router (preserving other search params), guards against exceeding
10991
+ * {@link MAX_URL_LENGTH}, and detects external URL changes (browser
10992
+ * back/forward, shared links) via an optional callback.
10993
+ *
10994
+ * @param options - Configuration for the search param sync.
10995
+ * @param options.key - The URL search parameter name.
10996
+ * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
10997
+ * @param options.onExternalChange - Called when the param changes externally.
10998
+ * @param options.replace - `true` to always use `replaceState`.
10999
+ */
11000
+ const useSearchParamSync = ({ key, enabled = true, onExternalChange, replace: replaceOption, }) => {
11001
+ const navigate = useNavigate();
11002
+ const location = useLocation();
11003
+ const search = useSearch({ strict: false, shouldThrow: false });
11004
+ const lastWrittenRef = useRef(undefined);
11005
+ const onExternalChangeRef = useRef(onExternalChange);
11006
+ useEffect(() => {
11007
+ onExternalChangeRef.current = onExternalChange;
11008
+ }, [onExternalChange]);
11009
+ const currentSearchValue = useMemo(() => {
11010
+ if (!enabled) {
11011
+ return undefined;
11012
+ }
11013
+ const value = search?.[key];
11014
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
11015
+ return String(value);
11016
+ }
11017
+ return undefined;
11018
+ }, [enabled, search, key]);
11019
+ useEffect(() => {
11020
+ if (!enabled) {
11021
+ return;
11022
+ }
11023
+ if (currentSearchValue === undefined) {
11024
+ return;
11025
+ }
11026
+ if (currentSearchValue === lastWrittenRef.current) {
11027
+ return;
11028
+ }
11029
+ if (lastWrittenRef.current === undefined) {
11030
+ return;
11031
+ }
11032
+ requestAnimationFrame(() => {
11033
+ onExternalChangeRef.current?.();
11034
+ });
11035
+ }, [currentSearchValue, enabled]);
11036
+ const getUrlLengthWithSearchParam = useCallback((paramKey, paramValue, params) => {
11037
+ const otherParamsLength = objectKeys(params)
11038
+ .filter(k => k !== paramKey)
11039
+ .reduce((totalLength, k) => {
11040
+ const kLen = encodeURIComponent(String(k)).length;
11041
+ const v = params[k];
11042
+ const vLen = v !== null && v !== undefined ? encodeURIComponent(String(v)).length : 0;
11043
+ return totalLength + kLen + vLen + (totalLength > 0 ? 2 : 1);
11044
+ }, 0);
11045
+ const urlBase = location.href.length - (location.searchStr.length || 0) + (location.hash.length || 0);
11046
+ return urlBase + otherParamsLength + 1 + paramKey.length + 1 + paramValue.length;
11047
+ }, [location]);
11048
+ const updateSearchParam = useCallback((encodedValue) => {
11049
+ if (!enabled) {
11050
+ return;
11051
+ }
11052
+ if (encodedValue !== undefined && encodedValue === lastWrittenRef.current) {
11053
+ return;
11054
+ }
11055
+ lastWrittenRef.current = encodedValue;
11056
+ if (currentSearchValue === encodedValue) {
11057
+ return;
11058
+ }
11059
+ requestAnimationFrame(() => {
11060
+ const shouldReplace = replaceOption ?? !Boolean(currentSearchValue);
11061
+ void navigate({
11062
+ to: ".",
11063
+ search: (prev) => {
11064
+ if (getUrlLengthWithSearchParam(key, encodedValue || "", prev) <= MAX_URL_LENGTH) {
11065
+ return { ...prev, [key]: encodedValue };
11066
+ }
11067
+ else {
11068
+ return { ...prev, [key]: undefined };
11069
+ }
11070
+ },
11071
+ hash: location.hash,
11072
+ replace: shouldReplace,
11073
+ });
11074
+ });
11075
+ }, [enabled, navigate, key, replaceOption, location.hash, getUrlLengthWithSearchParam, currentSearchValue]);
11076
+ return useMemo(() => ({ searchValue: currentSearchValue, updateSearchParam }), [currentSearchValue, updateSearchParam]);
11077
+ };
11078
+
11079
+ /**
11080
+ * Generates a localStorage key, optionally scoped to a specific user.
8896
11081
  *
8897
- * @template TState - The type of the stored state.
8898
- * @template TAction - The type of the reducer action.
8899
- * @param options - Key, defaultState, schema, reducer, and optional callbacks.
8900
- * @param options.key - The storage key.
8901
- * @param options.defaultState - Fallback value when no stored data exists.
8902
- * @param options.schema - Zod schema for validation.
8903
- * @param options.reducer - The reducer function.
8904
- * @param options.onValidationFailed - Optional error callback.
8905
- * @param options.onValidationSuccessful - Optional success callback.
8906
- * @returns {Array} A tuple of [state, dispatch].
8907
- */
8908
- const useLocalStorageReducer = (options) => useWebStorageReducer(globalThis.localStorage, options);
8909
-
8910
- /**
8911
- * Works like useState, but persists to sessionStorage with Zod schema validation
8912
- * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
11082
+ * When `userId` is provided the key is `"key-userId"`.
11083
+ * When omitted the key is returned as-is (unscoped).
8913
11084
  *
8914
- * @template TState - The type of the stored state.
8915
- * @param options - Key, defaultState, schema, and optional callbacks.
8916
- * @param options.key - The storage key.
8917
- * @param options.defaultState - Fallback value when no stored data exists.
8918
- * @param options.schema - Zod schema for validation.
8919
- * @param options.onValidationFailed - Optional error callback.
8920
- * @param options.onValidationSuccessful - Optional success callback.
8921
- * @returns {Array} A tuple of [state, setState, reset].
11085
+ * @param key - Unique persistence identifier.
11086
+ * @param userId - Client-side user id to scope storage per user.
11087
+ * @returns {string} The combined storage key.
8922
11088
  */
8923
- const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage, options);
11089
+ const useStorageKey = (key, userId) => {
11090
+ return useMemo(() => (userId ? `${key}-${userId}` : key), [key, userId]);
11091
+ };
8924
11092
 
8925
11093
  /**
8926
- * Works like useReducer, but persists to sessionStorage with Zod schema validation
8927
- * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
11094
+ * Generic persistence hook that loads state from URL search params (with
11095
+ * localStorage fallback) and writes changes to both.
8928
11096
  *
8929
- * @template TState - The type of the stored state.
8930
- * @template TAction - The type of the reducer action.
8931
- * @param options - Key, defaultState, schema, reducer, and optional callbacks.
8932
- * @param options.key - The storage key.
8933
- * @param options.defaultState - Fallback value when no stored data exists.
8934
- * @param options.schema - Zod schema for validation.
8935
- * @param options.reducer - The reducer function.
8936
- * @param options.onValidationFailed - Optional error callback.
8937
- * @param options.onValidationSuccessful - Optional success callback.
8938
- * @returns {Array} A tuple of [state, dispatch].
11097
+ * On mount the hook tries, in order:
11098
+ * 1. Decode the URL search param and pass it through `validate`.
11099
+ * 2. If that yields nothing, read localStorage and pass the parsed JSON through `validate`.
11100
+ *
11101
+ * `persistState` writes the state to localStorage (via `serialize`, default
11102
+ * `JSON.stringify`) and syncs to the URL (via `toUrlValue` + encoding).
11103
+ * Duplicate writes where the state has not changed (deep equality) are skipped.
11104
+ *
11105
+ * @param options - Configuration for the persisted state.
11106
+ * @param options.key - Unique identifier used for both the URL search param and the localStorage key.
11107
+ * @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
11108
+ * @param options.serialize - Custom localStorage serialiser (default `storageSerializer.serialize`).
11109
+ * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
11110
+ * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
11111
+ * @param options.enabled - When `false` the URL is neither read nor written (default `true`).
11112
+ * @param options.onExternalChange - Fired when the URL param changes externally (e.g. browser back).
11113
+ * @param options.replace - Forwarded to `useSearchParamSync`.
11114
+ * @param options.clientSideUserId - The user ID to use for the localStorage key.
8939
11115
  */
8940
- const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
11116
+ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, onExternalChange, replace, clientSideUserId, }) => {
11117
+ const { encode, decode } = useCustomEncoding();
11118
+ const { searchValue, updateSearchParam } = useSearchParamSync({
11119
+ key,
11120
+ enabled,
11121
+ onExternalChange,
11122
+ replace,
11123
+ });
11124
+ const storageKey = useStorageKey(key, clientSideUserId);
11125
+ const validateRef = useRef(validate);
11126
+ useEffect(() => {
11127
+ validateRef.current = validate;
11128
+ }, [validate]);
11129
+ const toUrlValueRef = useRef(toUrlValue);
11130
+ useEffect(() => {
11131
+ toUrlValueRef.current = toUrlValue;
11132
+ }, [toUrlValue]);
11133
+ const fromUrlValueRef = useRef(fromUrlValue);
11134
+ useEffect(() => {
11135
+ fromUrlValueRef.current = fromUrlValue;
11136
+ }, [fromUrlValue]);
11137
+ const serializeRef = useRef(serialize);
11138
+ useEffect(() => {
11139
+ serializeRef.current = serialize;
11140
+ }, [serialize]);
11141
+ const [initialState] = useState(() => {
11142
+ if (enabled && searchValue) {
11143
+ try {
11144
+ const decoded = decode(searchValue);
11145
+ const transformed = fromUrlValue ? fromUrlValue(decoded) : decoded;
11146
+ const validated = validate(transformed);
11147
+ if (validated !== undefined) {
11148
+ return validated;
11149
+ }
11150
+ }
11151
+ catch {
11152
+ // fall through to localStorage
11153
+ }
11154
+ }
11155
+ try {
11156
+ const raw = localStorage.getItem(storageKey);
11157
+ if (raw) {
11158
+ const parsed = storageSerializer.deserialize(raw);
11159
+ return validate(parsed);
11160
+ }
11161
+ }
11162
+ catch {
11163
+ // no valid stored state
11164
+ }
11165
+ return undefined;
11166
+ });
11167
+ const lastPersistedRef = useRef(initialState);
11168
+ const persistState = useCallback((state) => {
11169
+ if (dequal(lastPersistedRef.current, state)) {
11170
+ return;
11171
+ }
11172
+ lastPersistedRef.current = state;
11173
+ const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
11174
+ localStorage.setItem(storageKey, serialized);
11175
+ const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
11176
+ updateSearchParam(encode(urlValue));
11177
+ }, [storageKey, encode, updateSearchParam]);
11178
+ return useMemo(() => ({ initialState, persistState }), [initialState, persistState]);
11179
+ };
8941
11180
 
8942
11181
  const OVERSCAN = 10;
8943
11182
  const DEFAULT_ROW_HEIGHT = 50;
@@ -9348,21 +11587,6 @@ const useElevatedState = (initialState, customState) => {
9348
11587
  return useMemo(() => customState ?? [fallbackValue, fallbackSetter], [customState, fallbackValue, fallbackSetter]);
9349
11588
  };
9350
11589
 
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
11590
  /**
9367
11591
  * Custom hook for checking if the browser is in fullscreen mode.
9368
11592
  */
@@ -10031,184 +12255,6 @@ const getWindowSize = () => {
10031
12255
  }
10032
12256
  };
10033
12257
 
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
12258
  /**
10213
12259
  * A useRef that updates its given value whenever it changes.
10214
12260
  *
@@ -10276,4 +12322,4 @@ const useWindowActivity = ({ onFocus, onBlur, skip = false } = { onBlur: undefin
10276
12322
  return useMemo(() => ({ focused }), [focused]);
10277
12323
  };
10278
12324
 
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 };
12325
+ 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, MAX_URL_LENGTH, 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, storageSerializer, 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, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };