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