@stevederico/skateboard-ui 4.9.0 → 4.11.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.
@@ -53,8 +53,19 @@ function RadioGroupItem({ className, value, onClick, disabled, ref, ...props })
53
53
  if (selected !== undefined)
54
54
  return;
55
55
  const el = btnRef.current;
56
- const first = el?.parentElement?.querySelector('[role="radio"]:not([disabled]):not([data-disabled])');
56
+ // Scope to the radiogroup (not the immediate parent) so per-item wrappers
57
+ // like <label>/<div> don't change which radio counts as "first enabled" —
58
+ // this matches the arrow-key handler, which queries the group element.
59
+ const group = el?.closest('[role="radiogroup"]');
60
+ const first = (group ?? el)?.querySelector('[role="radio"]:not([disabled]):not([data-disabled])');
57
61
  setIsFirstEnabled(first === el);
62
+ // No dependency array on purpose: "first enabled" depends on the whole
63
+ // sibling set, so an item must recompute when ANY sibling's disabled state
64
+ // or order changes — a per-item dep list ([selected, value, disabled])
65
+ // misses those cross-sibling changes and can leave the group with zero tab
66
+ // stops. The body is cheap (two querySelectors) and setIsFirstEnabled bails
67
+ // out of re-render when the value is unchanged, so running every render is
68
+ // negligible.
58
69
  });
59
70
  const tabbable = checked || (selected === undefined && isFirstEnabled);
60
71
  return (_jsx("button", { ref: mergeRefs(btnRef, ref), type: "button", role: "radio", "aria-checked": checked, disabled: disabled, tabIndex: tabbable ? 0 : -1, "data-slot": "radio-group-item", "data-state": checked ? "checked" : "unchecked", "data-checked": checked ? "" : undefined, className: cn("cursor-pointer group/radio-group-item peer relative flex aspect-square size-4 shrink-0 items-center justify-center rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary", className), onClick: (e) => {
package/dist/ui/select.js CHANGED
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import { cn } from "../shadcn/lib/utils.js";
5
5
  import { mergeRefs } from "./slot.js";
6
6
  import { Portal } from "./portal.js";
7
+ import { registerLayer } from "./layer-stack.js";
7
8
  import { useFloating } from "./use-floating.js";
8
9
  import { usePresence } from "./use-presence.js";
9
10
  import { useControllableState } from "./use-controllable-state.js";
@@ -33,10 +34,6 @@ function Select({ value, defaultValue, onValueChange, open, defaultOpen = false,
33
34
  const triggerId = React.useId();
34
35
  const [labels, setLabels] = React.useState({});
35
36
  const registerLabel = React.useCallback((v, label) => setLabels((prev) => ({ ...prev, [v]: label })), []);
36
- const unregisterLabel = React.useCallback((v) => setLabels((prev) => {
37
- const { [v]: _, ...rest } = prev;
38
- return rest;
39
- }), []);
40
37
  const setValue = React.useCallback((v) => {
41
38
  setSelected(v);
42
39
  setOpen(false);
@@ -52,7 +49,6 @@ function Select({ value, defaultValue, onValueChange, open, defaultOpen = false,
52
49
  triggerId,
53
50
  labels,
54
51
  registerLabel,
55
- unregisterLabel,
56
52
  }, children: children }));
57
53
  }
58
54
  function SelectGroup({ className, ...props }) {
@@ -124,6 +120,8 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
124
120
  React.useEffect(() => {
125
121
  if (!open)
126
122
  return;
123
+ const node = containerRef.current;
124
+ const off = node ? registerLayer(node) : undefined;
127
125
  const onPointerDown = (e) => {
128
126
  const t = e.target;
129
127
  if (triggerRef.current?.contains(t))
@@ -135,6 +133,7 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
135
133
  document.addEventListener("pointerdown", onPointerDown, true);
136
134
  return () => {
137
135
  document.removeEventListener("pointerdown", onPointerDown, true);
136
+ off?.();
138
137
  };
139
138
  }, [open, setOpen, triggerRef]);
140
139
  // While closed, render the items into a hidden tree so their value→label
package/dist/ui/sheet.js CHANGED
@@ -28,9 +28,12 @@ function Sheet({ open, defaultOpen = false, onOpenChange, children }) {
28
28
  // Title/Description part is actually rendered, so the dialog never points at a
29
29
  // missing element (a dangling IDREF that screen readers announce as broken).
30
30
  const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy } = useDialogLabelling();
31
+ // Stable id wiring the trigger's aria-controls to the content's id.
32
+ const contentId = React.useId();
31
33
  return (_jsx(SheetContext.Provider, { value: {
32
34
  open: isOpen,
33
35
  setOpen,
36
+ contentId,
34
37
  titleId,
35
38
  descriptionId,
36
39
  registerTitle,
@@ -40,10 +43,10 @@ function Sheet({ open, defaultOpen = false, onOpenChange, children }) {
40
43
  }, children: children }));
41
44
  }
42
45
  function SheetTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
43
- const { open, setOpen } = useSheet();
46
+ const { open, setOpen, contentId } = useSheet();
44
47
  const { useSlot, slotChild } = resolveRender(asChild, render, children);
45
48
  const Comp = useSlot ? Slot : "button";
46
- return (_jsx(Comp, { type: useSlot ? undefined : "button", "data-slot": "sheet-trigger", "aria-haspopup": "dialog", "data-state": open ? "open" : "closed", onClick: (e) => {
49
+ return (_jsx(Comp, { type: useSlot ? undefined : "button", "data-slot": "sheet-trigger", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-state": open ? "open" : "closed", onClick: (e) => {
47
50
  onClick?.(e);
48
51
  if (!e.defaultPrevented)
49
52
  setOpen(true);
@@ -80,7 +83,7 @@ const SIDE_OUT = {
80
83
  bottom: "slide-out-to-bottom-52",
81
84
  };
82
85
  function SheetContent({ className, children, side = "right", showCloseButton = true, ...props }) {
83
- const { open, setOpen, labelledBy, describedBy } = useSheet();
86
+ const { open, setOpen, contentId, labelledBy, describedBy } = useSheet();
84
87
  const ref = React.useRef(null);
85
88
  const pointerDownOnBackdrop = React.useRef(false);
86
89
  const [mounted, presenceRef] = usePresence(open);
@@ -98,7 +101,7 @@ function SheetContent({ className, children, side = "right", showCloseButton = t
98
101
  useScrollLock(mounted);
99
102
  if (!mounted)
100
103
  return null;
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) => {
104
+ return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), id: contentId, "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) => {
102
105
  e.preventDefault();
103
106
  setOpen(false);
104
107
  }, onClose: () => setOpen(false), onPointerDown: (e) => {
package/dist/ui/slider.js CHANGED
@@ -4,6 +4,17 @@ import * as React from "react";
4
4
  import { cn } from "../shadcn/lib/utils.js";
5
5
  import { useControllableState } from "./use-controllable-state.js";
6
6
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
7
+ // Snap a value to the step grid relative to `min`, then round away the
8
+ // floating-point drift that `Math.round(x / step) * step` introduces (e.g.
9
+ // 0.1 * 3 === 0.30000000000000004) so value/aria-valuenow stay clean.
10
+ const snapToStep = (v, min, step) => {
11
+ if (step <= 0)
12
+ return v;
13
+ const snapped = min + Math.round((v - min) / step) * step;
14
+ // Derive the decimal precision from the step (e.g. 0.1 -> 1, 0.25 -> 2).
15
+ const decimals = (String(step).split(".")[1] ?? "").length;
16
+ return decimals > 0 ? Number(snapped.toFixed(decimals)) : snapped;
17
+ };
7
18
  /**
8
19
  * Self-contained slider with pointer drag and keyboard control. Supports one or
9
20
  * more thumbs (the nearest thumb to the pointer is the one that moves). Track,
@@ -21,6 +32,12 @@ function Slider({ className, value, defaultValue, onValueChange, min = 0, max =
21
32
  const trackRef = React.useRef(null);
22
33
  const activeThumb = React.useRef(0);
23
34
  const vertical = orientation === "vertical";
35
+ // Track the latest values so pointer-move handlers (which close over the
36
+ // pointerdown-time render) read fresh state — otherwise, in controlled mode,
37
+ // a parent correction (e.g. min-gap push between thumbs) gets overwritten on
38
+ // the next move.
39
+ const valuesRef = React.useRef(values);
40
+ valuesRef.current = values;
24
41
  const percent = (v) => ((v - min) / (max - min)) * 100;
25
42
  const valueFromPointer = (clientX, clientY) => {
26
43
  const track = trackRef.current;
@@ -31,17 +48,28 @@ function Slider({ className, value, defaultValue, onValueChange, min = 0, max =
31
48
  ? (rect.bottom - clientY) / rect.height
32
49
  : (clientX - rect.left) / rect.width;
33
50
  const raw = min + clamp(ratio, 0, 1) * (max - min);
34
- return clamp(Math.round(raw / step) * step, min, max);
51
+ return clamp(snapToStep(raw, min, step), min, max);
35
52
  };
36
53
  const updateThumb = (index, next) => {
37
- setValues(values.map((v, i) => (i === index ? next : v)));
54
+ const current = valuesRef.current;
55
+ // Clamp the moving thumb against its immediate neighbors so values stay
56
+ // ordered. When neighbors share this thumb's value (overlap), only the
57
+ // actively-dragged thumb is allowed to move away — clamping against the
58
+ // neighbor on the side it's heading lets it pass instead of pinning it.
59
+ const lower = index > 0 ? current[index - 1] : min;
60
+ const upper = index < current.length - 1 ? current[index + 1] : max;
61
+ const clamped = clamp(next, lower, upper);
62
+ setValues(current.map((v, i) => (i === index ? clamped : v)));
38
63
  };
39
64
  const nearestThumb = (v) => {
40
65
  let best = 0;
41
66
  let bestDist = Infinity;
42
- values.forEach((tv, i) => {
67
+ valuesRef.current.forEach((tv, i) => {
43
68
  const d = Math.abs(tv - v);
44
- if (d < bestDist) {
69
+ // On exact ties prefer the thumb whose move direction matches the
70
+ // pointer, so overlapping thumbs don't both resolve to the lowest index
71
+ // (which would leave the higher-index thumb unmovable).
72
+ if (d < bestDist || (d === bestDist && v > tv)) {
45
73
  bestDist = d;
46
74
  best = i;
47
75
  }
@@ -56,27 +84,39 @@ function Slider({ className, value, defaultValue, onValueChange, min = 0, max =
56
84
  activeThumb.current = idx;
57
85
  updateThumb(idx, v);
58
86
  const target = e.currentTarget;
59
- target.setPointerCapture(e.pointerId);
87
+ const pointerId = e.pointerId;
88
+ target.setPointerCapture(pointerId);
60
89
  const onMove = (ev) => {
61
90
  updateThumb(activeThumb.current, valueFromPointer(ev.clientX, ev.clientY));
62
91
  };
63
- const onUp = (ev) => {
64
- target.releasePointerCapture(ev.pointerId);
92
+ // Single teardown for every drag-ending event so a cancelled or lost
93
+ // pointer (pointercancel/lostpointercapture) doesn't leak the move/up
94
+ // listeners and leave the drag stuck.
95
+ const teardown = () => {
96
+ if (target.hasPointerCapture(pointerId)) {
97
+ target.releasePointerCapture(pointerId);
98
+ }
65
99
  target.removeEventListener("pointermove", onMove);
66
- target.removeEventListener("pointerup", onUp);
100
+ target.removeEventListener("pointerup", teardown);
101
+ target.removeEventListener("pointercancel", teardown);
102
+ target.removeEventListener("lostpointercapture", teardown);
67
103
  };
68
104
  target.addEventListener("pointermove", onMove);
69
- target.addEventListener("pointerup", onUp);
105
+ target.addEventListener("pointerup", teardown);
106
+ target.addEventListener("pointercancel", teardown);
107
+ target.addEventListener("lostpointercapture", teardown);
70
108
  };
71
109
  const onThumbKeyDown = (index) => (e) => {
72
110
  if (disabled)
73
111
  return;
74
112
  let next = null;
75
113
  const cur = values[index];
114
+ // Round each step back onto the grid so repeated arrow presses don't
115
+ // accumulate float drift in value/aria-valuenow.
76
116
  if (e.key === "ArrowRight" || e.key === "ArrowUp")
77
- next = cur + step;
117
+ next = snapToStep(cur + step, min, step);
78
118
  else if (e.key === "ArrowLeft" || e.key === "ArrowDown")
79
- next = cur - step;
119
+ next = snapToStep(cur - step, min, step);
80
120
  else if (e.key === "Home")
81
121
  next = min;
82
122
  else if (e.key === "End")
package/dist/ui/slot.js CHANGED
@@ -24,7 +24,9 @@ function Slot({ children, className, style, ref, ...props }) {
24
24
  const childProps = child.props;
25
25
  // Child props win on plain values, but event handlers (on*) are COMPOSED so
26
26
  // the slot-injected behavior (open/close/etc.) runs alongside the child's own
27
- // handler — matching Radix Slot / Base UI mergeProps instead of clobbering it.
27
+ // handler — matching Radix composeEventHandlers instead of clobbering it. The
28
+ // child's own handler runs FIRST; the forwarded slot handler runs ONLY IF the
29
+ // child hasn't called preventDefault, so a child can opt out of slot behavior.
28
30
  const merged = { ...props, ...childProps };
29
31
  for (const key of Object.keys(props)) {
30
32
  const slotHandler = props[key];
@@ -34,11 +36,11 @@ function Slot({ children, className, style, ref, ...props }) {
34
36
  typeof childHandler === "function") {
35
37
  merged[key] = (...args) => {
36
38
  ;
37
- slotHandler(...args);
39
+ childHandler(...args);
38
40
  const event = args[0];
39
41
  if (!event?.defaultPrevented) {
40
42
  ;
41
- childHandler(...args);
43
+ slotHandler(...args);
42
44
  }
43
45
  };
44
46
  }
package/dist/ui/tabs.js CHANGED
@@ -70,18 +70,25 @@ function TabsTrigger({ className, value, onClick, ref, ...props }) {
70
70
  const { value: active, setValue, idBase } = useTabs();
71
71
  const selected = active === value;
72
72
  const btnRef = React.useRef(null);
73
- // Roving tab index: the selected tab is tabbable. When nothing is selected the
74
- // first enabled tab must still be reachable so the tablist stays in Tab order
73
+ // Roving tab index: the selected tab is tabbable. When no trigger matches the
74
+ // active value — undefined, or a value with no rendered trigger — the first
75
+ // enabled tab must still be reachable so the tablist stays in Tab order
75
76
  // (WAI-ARIA: a tablist always has exactly one tab stop).
76
- const [isFirstEnabled, setIsFirstEnabled] = React.useState(false);
77
+ const [isFallbackStop, setIsFallbackStop] = React.useState(false);
77
78
  React.useLayoutEffect(() => {
78
- if (active !== undefined)
79
- return;
80
79
  const el = btnRef.current;
81
- const first = el?.parentElement?.querySelector('[role="tab"]:not([disabled]):not([data-disabled])');
82
- setIsFirstEnabled(first === el);
80
+ const list = el?.parentElement;
81
+ if (!list)
82
+ return;
83
+ const anySelected = list.querySelector('[role="tab"][aria-selected="true"]');
84
+ if (anySelected) {
85
+ setIsFallbackStop(false);
86
+ return;
87
+ }
88
+ const first = list.querySelector('[role="tab"]:not([disabled]):not([data-disabled])');
89
+ setIsFallbackStop(first === el);
83
90
  });
84
- const tabbable = selected || (active === undefined && isFirstEnabled);
91
+ const tabbable = selected || isFallbackStop;
85
92
  return (_jsx("button", { ref: mergeRefs(btnRef, ref), type: "button", role: "tab", id: `${idBase}-tab-${idify(value)}`, "aria-selected": selected, "aria-controls": `${idBase}-panel-${idify(value)}`, tabIndex: tabbable ? 0 : -1, "data-slot": "tabs-trigger", "data-active": selected ? "" : undefined, className: cn("cursor-pointer relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", className), onClick: (e) => {
86
93
  onClick?.(e);
87
94
  if (!e.defaultPrevented)
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
+ import { isInLayer } from "./layer-stack.js";
3
4
  // Shared stack of open dismissable layers (most-recently-opened last). Escape
4
5
  // only closes the topmost layer, so a popover opened inside a dialog doesn't
5
6
  // close both at once.
@@ -35,6 +36,8 @@ export function useDismiss(open, onDismiss, refs) {
35
36
  return;
36
37
  if (refs.some((r) => r.current && r.current.contains(target)))
37
38
  return;
39
+ if (isInLayer(target))
40
+ return;
38
41
  onDismissRef.current();
39
42
  };
40
43
  document.addEventListener("keydown", onKeyDown, true);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stevederico/skateboard-ui",
3
3
  "private": false,
4
- "version": "4.9.0",
4
+ "version": "4.11.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {