@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/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
- const titleId = React.useId();
26
- const descriptionId = React.useId();
27
- return (_jsx(SheetContext.Provider, { value: { open: isOpen, setOpen, titleId, descriptionId }, children: children }));
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, titleId, descriptionId } = useSheet();
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
- React.useEffect(() => {
84
- if (!mounted)
85
- return;
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": titleId, "aria-describedby": descriptionId, "data-slot": "sheet-content", "data-side": side, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
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, };
@@ -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, ...props }) {
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 (_jsx("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
89
- ? { bottom: `${rangeStart}%`, height: `${rangeEnd - rangeStart}%` }
90
- : { left: `${rangeStart}%`, width: `${rangeEnd - rangeStart}%` } }) }), values.map((v, index) => (_jsx("span", { role: "slider", tabIndex: disabled ? -1 : 0, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": v, "aria-orientation": orientation, "aria-disabled": disabled || undefined, "data-slot": "slider-thumb", 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 disabled:pointer-events-none disabled:opacity-50 data-vertical:translate-x-0 data-vertical:-translate-y-1/2", "data-orientation": orientation, style: vertical
91
- ? { bottom: `${percent(v)}%` }
92
- : { left: `${percent(v)}%` } }, index)))] }) }));
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 };
@@ -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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stevederico/skateboard-ui",
3
3
  "private": false,
4
- "version": "4.1.0",
4
+ "version": "4.5.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {