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