@stevederico/skateboard-ui 4.1.0 → 4.5.0
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/CHANGELOG.md +35 -0
- package/README.md +3 -1
- package/dist/components/ThemeToggle.js +2 -2
- package/dist/components/core/Context.js +6 -6
- package/dist/components/core/Utilities.d.ts +2 -1
- package/dist/components/core/Utilities.js +20 -19
- package/dist/ui/alert-dialog.js +24 -16
- package/dist/ui/checkbox.d.ts +9 -6
- package/dist/ui/checkbox.js +21 -9
- package/dist/ui/context-menu.js +28 -8
- package/dist/ui/dialog.js +18 -17
- package/dist/ui/drawer.js +28 -16
- package/dist/ui/dropdown-menu.js +44 -14
- package/dist/ui/menubar.js +35 -14
- package/dist/ui/popover.js +24 -5
- package/dist/ui/select.d.ts +1 -1
- package/dist/ui/select.js +27 -6
- package/dist/ui/sheet.js +27 -16
- package/dist/ui/slider.d.ts +2 -1
- package/dist/ui/slider.js +11 -6
- package/dist/ui/slot.d.ts +4 -0
- package/dist/ui/slot.js +19 -1
- package/dist/ui/tooltip.js +27 -1
- package/dist/ui/use-labelling.d.ts +21 -0
- package/dist/ui/use-labelling.js +43 -0
- package/dist/ui/use-pointer-moved.d.ts +12 -0
- package/dist/ui/use-pointer-moved.js +21 -0
- package/dist/ui/use-scroll-lock.d.ts +6 -0
- package/dist/ui/use-scroll-lock.js +29 -0
- package/package.json +1 -1
package/dist/ui/sheet.js
CHANGED
|
@@ -7,6 +7,8 @@ import { Slot, mergeRefs, resolveRender } from "./slot.js";
|
|
|
7
7
|
import { XIcon } from "../icons/index.js";
|
|
8
8
|
import { useControllableState } from "./use-controllable-state.js";
|
|
9
9
|
import { usePresence } from "./use-presence.js";
|
|
10
|
+
import { useScrollLock } from "./use-scroll-lock.js";
|
|
11
|
+
import { useDialogLabelling } from "./use-labelling.js";
|
|
10
12
|
const SheetContext = React.createContext(null);
|
|
11
13
|
function useSheet() {
|
|
12
14
|
const ctx = React.useContext(SheetContext);
|
|
@@ -22,9 +24,20 @@ function Sheet({ open, defaultOpen = false, onOpenChange, children }) {
|
|
|
22
24
|
defaultValue: defaultOpen,
|
|
23
25
|
onChange: onOpenChange,
|
|
24
26
|
});
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
// Conditional labelling: only advertise aria-labelledby/aria-describedby when a
|
|
28
|
+
// Title/Description part is actually rendered, so the dialog never points at a
|
|
29
|
+
// missing element (a dangling IDREF that screen readers announce as broken).
|
|
30
|
+
const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy } = useDialogLabelling();
|
|
31
|
+
return (_jsx(SheetContext.Provider, { value: {
|
|
32
|
+
open: isOpen,
|
|
33
|
+
setOpen,
|
|
34
|
+
titleId,
|
|
35
|
+
descriptionId,
|
|
36
|
+
registerTitle,
|
|
37
|
+
registerDescription,
|
|
38
|
+
labelledBy,
|
|
39
|
+
describedBy,
|
|
40
|
+
}, children: children }));
|
|
28
41
|
}
|
|
29
42
|
function SheetTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
|
|
30
43
|
const { open, setOpen } = useSheet();
|
|
@@ -67,7 +80,7 @@ const SIDE_OUT = {
|
|
|
67
80
|
bottom: "slide-out-to-bottom-52",
|
|
68
81
|
};
|
|
69
82
|
function SheetContent({ className, children, side = "right", showCloseButton = true, ...props }) {
|
|
70
|
-
const { open, setOpen,
|
|
83
|
+
const { open, setOpen, labelledBy, describedBy } = useSheet();
|
|
71
84
|
const ref = React.useRef(null);
|
|
72
85
|
const pointerDownOnBackdrop = React.useRef(false);
|
|
73
86
|
const [mounted, presenceRef] = usePresence(open);
|
|
@@ -80,18 +93,12 @@ function SheetContent({ className, children, side = "right", showCloseButton = t
|
|
|
80
93
|
else if (!mounted && node.open)
|
|
81
94
|
node.close();
|
|
82
95
|
}, [mounted]);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const prev = document.body.style.overflow;
|
|
87
|
-
document.body.style.overflow = "hidden";
|
|
88
|
-
return () => {
|
|
89
|
-
document.body.style.overflow = prev;
|
|
90
|
-
};
|
|
91
|
-
}, [mounted]);
|
|
96
|
+
// Reentrant, reference-counted scroll lock so stacked overlays don't unlock the
|
|
97
|
+
// body while another is still open (a plain per-instance effect would).
|
|
98
|
+
useScrollLock(mounted);
|
|
92
99
|
if (!mounted)
|
|
93
100
|
return null;
|
|
94
|
-
return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby":
|
|
101
|
+
return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "sheet-content", "data-side": side, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
|
|
95
102
|
e.preventDefault();
|
|
96
103
|
setOpen(false);
|
|
97
104
|
}, onClose: () => setOpen(false), onPointerDown: (e) => {
|
|
@@ -111,11 +118,15 @@ function SheetFooter({ className, ...props }) {
|
|
|
111
118
|
return (_jsx("div", { "data-slot": "sheet-footer", className: cn("mt-auto flex flex-col gap-2 p-4", className), ...props }));
|
|
112
119
|
}
|
|
113
120
|
function SheetTitle({ className, ...props }) {
|
|
114
|
-
const { titleId } = useSheet();
|
|
121
|
+
const { titleId, registerTitle } = useSheet();
|
|
122
|
+
// Register so the dialog only sets aria-labelledby while this title is mounted.
|
|
123
|
+
React.useEffect(registerTitle, [registerTitle]);
|
|
115
124
|
return (_jsx("h2", { id: titleId, "data-slot": "sheet-title", className: cn("font-medium text-foreground", className), ...props }));
|
|
116
125
|
}
|
|
117
126
|
function SheetDescription({ className, ...props }) {
|
|
118
|
-
const { descriptionId } = useSheet();
|
|
127
|
+
const { descriptionId, registerDescription } = useSheet();
|
|
128
|
+
// Register so the dialog only sets aria-describedby while this description is mounted.
|
|
129
|
+
React.useEffect(registerDescription, [registerDescription]);
|
|
119
130
|
return (_jsx("p", { id: descriptionId, "data-slot": "sheet-description", className: cn("text-sm text-muted-foreground", className), ...props }));
|
|
120
131
|
}
|
|
121
132
|
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription, };
|
package/dist/ui/slider.d.ts
CHANGED
|
@@ -8,11 +8,12 @@ export interface SliderProps extends Omit<React.ComponentProps<"div">, "defaultV
|
|
|
8
8
|
step?: number;
|
|
9
9
|
orientation?: "horizontal" | "vertical";
|
|
10
10
|
disabled?: boolean;
|
|
11
|
+
name?: string;
|
|
11
12
|
}
|
|
12
13
|
/**
|
|
13
14
|
* Self-contained slider with pointer drag and keyboard control. Supports one or
|
|
14
15
|
* more thumbs (the nearest thumb to the pointer is the one that moves). Track,
|
|
15
16
|
* range, and thumbs mirror the shadcn structure/classes.
|
|
16
17
|
*/
|
|
17
|
-
declare function Slider({ className, value, defaultValue, onValueChange, min, max, step, orientation, disabled, ...props }: SliderProps): React.JSX.Element;
|
|
18
|
+
declare function Slider({ className, value, defaultValue, onValueChange, min, max, step, orientation, disabled, name, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...props }: SliderProps): React.JSX.Element;
|
|
18
19
|
export { Slider };
|
package/dist/ui/slider.js
CHANGED
|
@@ -9,7 +9,10 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
|
9
9
|
* more thumbs (the nearest thumb to the pointer is the one that moves). Track,
|
|
10
10
|
* range, and thumbs mirror the shadcn structure/classes.
|
|
11
11
|
*/
|
|
12
|
-
function Slider({ className, value, defaultValue, onValueChange, min = 0, max = 100, step = 1, orientation = "horizontal", disabled = false,
|
|
12
|
+
function Slider({ className, value, defaultValue, onValueChange, min = 0, max = 100, step = 1, orientation = "horizontal", disabled = false, name,
|
|
13
|
+
// Pull the accessible-name props off so they don't land on the outer wrapper
|
|
14
|
+
// div; the thumb (role="slider") is the element that needs them.
|
|
15
|
+
"aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...props }) {
|
|
13
16
|
const [values, setValues] = useControllableState({
|
|
14
17
|
value,
|
|
15
18
|
defaultValue: defaultValue ?? [min],
|
|
@@ -85,10 +88,12 @@ function Slider({ className, value, defaultValue, onValueChange, min = 0, max =
|
|
|
85
88
|
};
|
|
86
89
|
const rangeStart = values.length > 1 ? Math.min(...values.map(percent)) : 0;
|
|
87
90
|
const rangeEnd = Math.max(...values.map(percent));
|
|
88
|
-
return (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
return (_jsxs("div", { "data-slot": "slider", "data-orientation": orientation, "data-disabled": disabled ? "" : undefined, className: cn("data-horizontal:w-full data-vertical:h-full", className), ...props, children: [_jsxs("div", { "data-orientation": orientation, onPointerDown: onPointerDown, className: "relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col", children: [_jsx("div", { ref: trackRef, "data-slot": "slider-track", "data-orientation": orientation, className: "relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5", children: _jsx("div", { "data-slot": "slider-range", "data-orientation": orientation, className: "absolute bg-primary select-none data-horizontal:h-full data-vertical:w-full", style: vertical
|
|
92
|
+
? { bottom: `${rangeStart}%`, height: `${rangeEnd - rangeStart}%` }
|
|
93
|
+
: { left: `${rangeStart}%`, width: `${rangeEnd - rangeStart}%` } }) }), values.map((v, index) => (_jsx("span", { role: "slider", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": v, "aria-orientation": orientation, "aria-disabled": disabled || undefined, "data-slot": "slider-thumb", "data-disabled": disabled ? "" : undefined, onKeyDown: onThumbKeyDown(index), className: "absolute block size-4 shrink-0 -translate-x-1/2 cursor-pointer rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 data-vertical:translate-x-0 data-vertical:-translate-y-1/2", "data-orientation": orientation, style: vertical
|
|
94
|
+
? { bottom: `${percent(v)}%` }
|
|
95
|
+
: { left: `${percent(v)}%` } }, index)))] }), name
|
|
96
|
+
? values.map((v, i) => (_jsx("input", { type: "hidden", name: name, value: v }, i)))
|
|
97
|
+
: null] }));
|
|
93
98
|
}
|
|
94
99
|
export { Slider };
|
package/dist/ui/slot.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ declare function Slot({ children, className, style, ref, ...props }: SlotProps):
|
|
|
19
19
|
* element to `render` to control the rendered element; here that maps onto the
|
|
20
20
|
* `asChild` + Slot mechanism. Returns whether to use the Slot and what child to
|
|
21
21
|
* render through it, so `render={<Button/>}` keeps working on the new components.
|
|
22
|
+
* Matching Base UI's render-prop semantics, the component's children become the
|
|
23
|
+
* render element's children when the element has none of its own — so
|
|
24
|
+
* `<Trigger render={<Button/>}>Columns</Trigger>` renders the label, while
|
|
25
|
+
* `render={<Button>Sign Out</Button>}` keeps the element's own children.
|
|
22
26
|
*/
|
|
23
27
|
declare function resolveRender(asChild: boolean | undefined, render: React.ReactElement | undefined, children: React.ReactNode): {
|
|
24
28
|
useSlot: boolean;
|
package/dist/ui/slot.js
CHANGED
|
@@ -53,10 +53,28 @@ function Slot({ children, className, style, ref, ...props }) {
|
|
|
53
53
|
* element to `render` to control the rendered element; here that maps onto the
|
|
54
54
|
* `asChild` + Slot mechanism. Returns whether to use the Slot and what child to
|
|
55
55
|
* render through it, so `render={<Button/>}` keeps working on the new components.
|
|
56
|
+
* Matching Base UI's render-prop semantics, the component's children become the
|
|
57
|
+
* render element's children when the element has none of its own — so
|
|
58
|
+
* `<Trigger render={<Button/>}>Columns</Trigger>` renders the label, while
|
|
59
|
+
* `render={<Button>Sign Out</Button>}` keeps the element's own children.
|
|
56
60
|
*/
|
|
57
61
|
function resolveRender(asChild, render, children) {
|
|
58
|
-
if (React.isValidElement(render))
|
|
62
|
+
if (React.isValidElement(render)) {
|
|
63
|
+
const renderProps = render.props;
|
|
64
|
+
const ownChildren = typeof renderProps === "object" &&
|
|
65
|
+
renderProps !== null &&
|
|
66
|
+
"children" in renderProps
|
|
67
|
+
? renderProps.children
|
|
68
|
+
: undefined;
|
|
69
|
+
// != null, not truthiness: 0 and "" are renderable children to keep.
|
|
70
|
+
if (ownChildren == null && children != null) {
|
|
71
|
+
return {
|
|
72
|
+
useSlot: true,
|
|
73
|
+
slotChild: React.cloneElement(render, undefined, children),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
59
76
|
return { useSlot: true, slotChild: render };
|
|
77
|
+
}
|
|
60
78
|
return { useSlot: !!asChild, slotChild: children };
|
|
61
79
|
}
|
|
62
80
|
export { Slot, mergeRefs, resolveRender };
|
package/dist/ui/tooltip.js
CHANGED
|
@@ -68,7 +68,7 @@ function TooltipTrigger({ asChild = false, render, nativeButton: _nativeButton,
|
|
|
68
68
|
}, ...props, children: slotChild }));
|
|
69
69
|
}
|
|
70
70
|
function TooltipContent({ className, style, ref, side = "top", sideOffset = 4, align = "center", alignOffset = 0, children, ...props }) {
|
|
71
|
-
const { open, contentId, triggerRef } = useTooltip();
|
|
71
|
+
const { open, contentId, triggerRef, hide } = useTooltip();
|
|
72
72
|
const [mounted, presenceRef] = usePresence(open);
|
|
73
73
|
const { floatingRef, pos } = useFloating(triggerRef, mounted, {
|
|
74
74
|
side,
|
|
@@ -76,6 +76,32 @@ function TooltipContent({ className, style, ref, side = "top", sideOffset = 4, a
|
|
|
76
76
|
sideOffset,
|
|
77
77
|
alignOffset,
|
|
78
78
|
});
|
|
79
|
+
// Touch dismissal: on touch devices a tooltip can open via tap/long-press
|
|
80
|
+
// (which focuses the trigger) with no pointer-leave/blur to close it again.
|
|
81
|
+
// While open, close it when the user points outside both the trigger and the
|
|
82
|
+
// content, and on scroll — so it can never get stuck on screen. Capture phase
|
|
83
|
+
// ensures we observe the pointerdown even if inner handlers stop propagation.
|
|
84
|
+
React.useEffect(() => {
|
|
85
|
+
if (!open)
|
|
86
|
+
return;
|
|
87
|
+
const onPointerDown = (e) => {
|
|
88
|
+
const target = e.target;
|
|
89
|
+
if (!(target instanceof Node))
|
|
90
|
+
return;
|
|
91
|
+
const trigger = triggerRef.current;
|
|
92
|
+
const content = presenceRef.current;
|
|
93
|
+
if (trigger?.contains(target) || content?.contains(target))
|
|
94
|
+
return;
|
|
95
|
+
hide();
|
|
96
|
+
};
|
|
97
|
+
const onScroll = () => hide();
|
|
98
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
99
|
+
window.addEventListener("scroll", onScroll, true);
|
|
100
|
+
return () => {
|
|
101
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
102
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
103
|
+
};
|
|
104
|
+
}, [open, hide, triggerRef, presenceRef]);
|
|
79
105
|
if (!mounted)
|
|
80
106
|
return null;
|
|
81
107
|
return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, ref), id: contentId, role: "tooltip", "data-slot": "tooltip-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, style: {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface DialogLabelling {
|
|
2
|
+
titleId: string;
|
|
3
|
+
descriptionId: string;
|
|
4
|
+
/** Call from the title element's effect; returns the unregister cleanup. */
|
|
5
|
+
registerTitle: () => () => void;
|
|
6
|
+
/** Call from the description element's effect; returns the unregister cleanup. */
|
|
7
|
+
registerDescription: () => () => void;
|
|
8
|
+
/** `aria-labelledby` value — undefined when no title is rendered, so the
|
|
9
|
+
* attribute is omitted instead of pointing at a missing element. */
|
|
10
|
+
labelledBy: string | undefined;
|
|
11
|
+
/** `aria-describedby` value — undefined when no description is rendered. */
|
|
12
|
+
describedBy: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Tracks whether a title/description part is actually rendered inside an overlay
|
|
16
|
+
* so the container can wire `aria-labelledby`/`aria-describedby` only when the
|
|
17
|
+
* referenced element exists. An unconditional IDREF to an absent node is an
|
|
18
|
+
* accessibility defect (dangling reference) that assistive tech may announce as
|
|
19
|
+
* an empty or broken label. Reference-counted so it is robust to remounting.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useDialogLabelling(): DialogLabelling;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Tracks whether a title/description part is actually rendered inside an overlay
|
|
5
|
+
* so the container can wire `aria-labelledby`/`aria-describedby` only when the
|
|
6
|
+
* referenced element exists. An unconditional IDREF to an absent node is an
|
|
7
|
+
* accessibility defect (dangling reference) that assistive tech may announce as
|
|
8
|
+
* an empty or broken label. Reference-counted so it is robust to remounting.
|
|
9
|
+
*/
|
|
10
|
+
export function useDialogLabelling() {
|
|
11
|
+
const titleId = React.useId();
|
|
12
|
+
const descriptionId = React.useId();
|
|
13
|
+
const titleCount = React.useRef(0);
|
|
14
|
+
const descriptionCount = React.useRef(0);
|
|
15
|
+
const [hasTitle, setHasTitle] = React.useState(false);
|
|
16
|
+
const [hasDescription, setHasDescription] = React.useState(false);
|
|
17
|
+
const registerTitle = React.useCallback(() => {
|
|
18
|
+
titleCount.current += 1;
|
|
19
|
+
setHasTitle(true);
|
|
20
|
+
return () => {
|
|
21
|
+
titleCount.current -= 1;
|
|
22
|
+
if (titleCount.current === 0)
|
|
23
|
+
setHasTitle(false);
|
|
24
|
+
};
|
|
25
|
+
}, []);
|
|
26
|
+
const registerDescription = React.useCallback(() => {
|
|
27
|
+
descriptionCount.current += 1;
|
|
28
|
+
setHasDescription(true);
|
|
29
|
+
return () => {
|
|
30
|
+
descriptionCount.current -= 1;
|
|
31
|
+
if (descriptionCount.current === 0)
|
|
32
|
+
setHasDescription(false);
|
|
33
|
+
};
|
|
34
|
+
}, []);
|
|
35
|
+
return {
|
|
36
|
+
titleId,
|
|
37
|
+
descriptionId,
|
|
38
|
+
registerTitle,
|
|
39
|
+
registerDescription,
|
|
40
|
+
labelledBy: hasTitle ? titleId : undefined,
|
|
41
|
+
describedBy: hasDescription ? descriptionId : undefined,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Guard against synthetic `pointermove` events that fire when content scrolls
|
|
4
|
+
* under a stationary pointer (e.g. keyboard arrow navigation scrolls the focused
|
|
5
|
+
* item into view). Those events report the SAME client coordinates as the last,
|
|
6
|
+
* so pointer-driven focus would otherwise yank focus off the keyboard target.
|
|
7
|
+
*
|
|
8
|
+
* Returns a predicate that is true only when the pointer's client position
|
|
9
|
+
* actually changed since the previous call — wire it at the top of a menu's
|
|
10
|
+
* `onPointerMove` and bail when it returns false.
|
|
11
|
+
*/
|
|
12
|
+
export declare function usePointerMoved(): (event: React.PointerEvent) => boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Guard against synthetic `pointermove` events that fire when content scrolls
|
|
5
|
+
* under a stationary pointer (e.g. keyboard arrow navigation scrolls the focused
|
|
6
|
+
* item into view). Those events report the SAME client coordinates as the last,
|
|
7
|
+
* so pointer-driven focus would otherwise yank focus off the keyboard target.
|
|
8
|
+
*
|
|
9
|
+
* Returns a predicate that is true only when the pointer's client position
|
|
10
|
+
* actually changed since the previous call — wire it at the top of a menu's
|
|
11
|
+
* `onPointerMove` and bail when it returns false.
|
|
12
|
+
*/
|
|
13
|
+
export function usePointerMoved() {
|
|
14
|
+
const last = React.useRef(null);
|
|
15
|
+
return React.useCallback((event) => {
|
|
16
|
+
const prev = last.current;
|
|
17
|
+
const moved = !prev || prev.x !== event.clientX || prev.y !== event.clientY;
|
|
18
|
+
last.current = { x: event.clientX, y: event.clientY };
|
|
19
|
+
return moved;
|
|
20
|
+
}, []);
|
|
21
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock background scroll while `active` is true, reentrant across any number of
|
|
3
|
+
* simultaneously-open overlays. The original `body` overflow is captured on the
|
|
4
|
+
* first lock and restored only when the last lock releases.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useScrollLock(active: boolean): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
// Module-level reference count so stacked overlays share one lock: the first to
|
|
4
|
+
// open saves and replaces `body` overflow, and only the last to close restores
|
|
5
|
+
// it. Without this, an inner overlay's cleanup would unlock scroll while an
|
|
6
|
+
// outer overlay is still open (or vice-versa).
|
|
7
|
+
let lockCount = 0;
|
|
8
|
+
let previousOverflow = "";
|
|
9
|
+
/**
|
|
10
|
+
* Lock background scroll while `active` is true, reentrant across any number of
|
|
11
|
+
* simultaneously-open overlays. The original `body` overflow is captured on the
|
|
12
|
+
* first lock and restored only when the last lock releases.
|
|
13
|
+
*/
|
|
14
|
+
export function useScrollLock(active) {
|
|
15
|
+
React.useEffect(() => {
|
|
16
|
+
if (!active)
|
|
17
|
+
return;
|
|
18
|
+
if (lockCount === 0) {
|
|
19
|
+
previousOverflow = document.body.style.overflow;
|
|
20
|
+
document.body.style.overflow = "hidden";
|
|
21
|
+
}
|
|
22
|
+
lockCount++;
|
|
23
|
+
return () => {
|
|
24
|
+
lockCount--;
|
|
25
|
+
if (lockCount === 0)
|
|
26
|
+
document.body.style.overflow = previousOverflow;
|
|
27
|
+
};
|
|
28
|
+
}, [active]);
|
|
29
|
+
}
|