@stevederico/skateboard-ui 4.0.0 → 4.3.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/tabs.js CHANGED
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { cva } from "../shadcn/lib/cva.js";
5
5
  import { cn } from "../shadcn/lib/utils.js";
6
+ import { mergeRefs } from "./slot.js";
6
7
  import { useControllableState } from "./use-controllable-state.js";
7
8
  const TabsContext = React.createContext(null);
8
9
  function useTabs() {
@@ -65,10 +66,23 @@ function TabsList({ className, variant = "default", onKeyDown, ...props }) {
65
66
  }
66
67
  }, ...props }));
67
68
  }
68
- function TabsTrigger({ className, value, onClick, ...props }) {
69
+ function TabsTrigger({ className, value, onClick, ref, ...props }) {
69
70
  const { value: active, setValue, idBase } = useTabs();
70
71
  const selected = active === value;
71
- return (_jsx("button", { type: "button", role: "tab", id: `${idBase}-tab-${idify(value)}`, "aria-selected": selected, "aria-controls": `${idBase}-panel-${idify(value)}`, tabIndex: selected ? 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) => {
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
75
+ // (WAI-ARIA: a tablist always has exactly one tab stop).
76
+ const [isFirstEnabled, setIsFirstEnabled] = React.useState(false);
77
+ React.useLayoutEffect(() => {
78
+ if (active !== undefined)
79
+ return;
80
+ const el = btnRef.current;
81
+ const first = el?.parentElement?.querySelector('[role="tab"]:not([disabled]):not([data-disabled])');
82
+ setIsFirstEnabled(first === el);
83
+ });
84
+ const tabbable = selected || (active === undefined && isFirstEnabled);
85
+ 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) => {
72
86
  onClick?.(e);
73
87
  if (!e.defaultPrevented)
74
88
  setValue(value);
@@ -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: {
@@ -13,6 +13,10 @@ export interface FloatingResult {
13
13
  side: Side;
14
14
  align: Align;
15
15
  transformOrigin: string;
16
+ /** Usable height (px) between the anchor and the viewport edge on the chosen
17
+ * side, less offsets/padding. Exposed as `--available-height` so scrollable
18
+ * popups (menus, selects) can clamp with `max-h-(--available-height)`. */
19
+ availableHeight: number;
16
20
  }
17
21
  /**
18
22
  * Pure positioning math: place a floating box relative to an anchor on the given
@@ -74,7 +74,11 @@ export function computePosition(anchor, floating, opts, viewport) {
74
74
  : align === "end"
75
75
  ? "bottom"
76
76
  : "center";
77
- return { x, y, side, align, transformOrigin: `${originX} ${originY}` };
77
+ // Space left for the popup on its final side, so it can clamp + scroll
78
+ // instead of overflowing the viewport. For left/right placements the popup
79
+ // spans the cross axis, so the usable height is the full viewport less padding.
80
+ const availableHeight = Math.max(0, (vertical ? space[side] - sideOffset : viewport.height) - PAD);
81
+ return { x, y, side, align, transformOrigin: `${originX} ${originY}`, availableHeight };
78
82
  }
79
83
  /**
80
84
  * Position a floating element against an anchor, recomputing on scroll, resize,
@@ -95,8 +99,12 @@ export function useFloating(anchorRef, open, options = {}) {
95
99
  setPos(computePosition(a.getBoundingClientRect(), { width: f.offsetWidth, height: f.offsetHeight }, { side, align, sideOffset, alignOffset }, { width: window.innerWidth, height: window.innerHeight }));
96
100
  }, [anchorRef, side, align, sideOffset, alignOffset]);
97
101
  React.useLayoutEffect(() => {
98
- if (!open)
102
+ // Drop the last position on close so a reopen stays visibility:hidden until
103
+ // it is re-measured, rather than flashing one frame at stale coordinates.
104
+ if (!open) {
105
+ setPos(null);
99
106
  return;
107
+ }
100
108
  const ro = new ResizeObserver(update);
101
109
  window.addEventListener("scroll", update, true);
102
110
  window.addEventListener("resize", update);
@@ -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
+ }
@@ -0,0 +1,19 @@
1
+ import * as React from "react";
2
+ /**
3
+ * WAI-ARIA typeahead for listbox/menu widgets. Collects printable keystrokes
4
+ * into a buffer that resets after a short pause, then resolves the item whose
5
+ * label matches the buffer (prefix match). Repeating a single character cycles
6
+ * through the items starting with it. The caller owns the items and decides how
7
+ * to focus/activate the returned match.
8
+ *
9
+ * Usage inside a keydown handler, after handling Arrow/Home/End/Enter/Escape/Tab:
10
+ * const match = onTypeaheadKeyDown(e, items, currentIndex)
11
+ * if (match) { e.preventDefault(); match.focus() }
12
+ *
13
+ * @returns `onTypeaheadKeyDown(event, items, activeIndex)` → matched element or null.
14
+ * `items` must be the enabled item elements in DOM order; `activeIndex` is the
15
+ * index of the currently focused item within `items` (-1 if none).
16
+ */
17
+ export declare function useTypeahead(): {
18
+ onTypeaheadKeyDown: (event: React.KeyboardEvent, items: HTMLElement[], activeIndex: number) => HTMLElement | null;
19
+ };
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ // How long a typeahead buffer survives between keystrokes before resetting, per
4
+ // the WAI-ARIA typeahead pattern (a short pause starts a fresh search).
5
+ const RESET_MS = 500;
6
+ /** The visible, trimmed, lower-cased label of an item, used for prefix matching. */
7
+ function labelOf(el) {
8
+ return (el.textContent ?? "").trim().toLowerCase();
9
+ }
10
+ /**
11
+ * WAI-ARIA typeahead for listbox/menu widgets. Collects printable keystrokes
12
+ * into a buffer that resets after a short pause, then resolves the item whose
13
+ * label matches the buffer (prefix match). Repeating a single character cycles
14
+ * through the items starting with it. The caller owns the items and decides how
15
+ * to focus/activate the returned match.
16
+ *
17
+ * Usage inside a keydown handler, after handling Arrow/Home/End/Enter/Escape/Tab:
18
+ * const match = onTypeaheadKeyDown(e, items, currentIndex)
19
+ * if (match) { e.preventDefault(); match.focus() }
20
+ *
21
+ * @returns `onTypeaheadKeyDown(event, items, activeIndex)` → matched element or null.
22
+ * `items` must be the enabled item elements in DOM order; `activeIndex` is the
23
+ * index of the currently focused item within `items` (-1 if none).
24
+ */
25
+ export function useTypeahead() {
26
+ const bufferRef = React.useRef("");
27
+ const timerRef = React.useRef(0);
28
+ React.useEffect(() => () => window.clearTimeout(timerRef.current), []);
29
+ const onTypeaheadKeyDown = React.useCallback((event, items, activeIndex) => {
30
+ const key = event.key;
31
+ // Single printable char only. Space is reserved for activation, and any
32
+ // modifier combo (Ctrl/Meta/Alt) is a command, not text.
33
+ if (key.length !== 1 ||
34
+ key === " " ||
35
+ event.ctrlKey ||
36
+ event.metaKey ||
37
+ event.altKey)
38
+ return null;
39
+ window.clearTimeout(timerRef.current);
40
+ timerRef.current = window.setTimeout(() => {
41
+ bufferRef.current = "";
42
+ }, RESET_MS);
43
+ const prev = bufferRef.current;
44
+ bufferRef.current += key.toLowerCase();
45
+ const buffer = bufferRef.current;
46
+ // A repeated single character cycles through items starting with it rather
47
+ // than searching for the literal repetition (e.g. "aa" → next "a…" item).
48
+ const allSame = buffer.length > 1 && [...buffer].every((c) => c === buffer[0]);
49
+ const query = allSame ? buffer[0] : buffer;
50
+ const n = items.length;
51
+ if (n === 0)
52
+ return null;
53
+ // Extending an in-progress match keeps resolving from the current item;
54
+ // a fresh search or a cycle advances past it.
55
+ const start = allSame || prev === "" ? Math.max(0, activeIndex) + 1 : Math.max(0, activeIndex);
56
+ for (let i = 0; i < n; i++) {
57
+ const item = items[(start + i) % n];
58
+ if (labelOf(item).startsWith(query))
59
+ return item;
60
+ }
61
+ return null;
62
+ }, []);
63
+ return { onTypeaheadKeyDown };
64
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stevederico/skateboard-ui",
3
3
  "private": false,
4
- "version": "4.0.0",
4
+ "version": "4.3.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {