@stevederico/skateboard-ui 4.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # CHANGELOG
2
2
 
3
+ 4.3.0
4
+
5
+ Submenu aria-controls wiring
6
+ Menu labelled by trigger
7
+ Tooltip dismiss on outside tap
8
+ Checkbox indeterminate mixed state
9
+ Replace catch any narrowing
10
+ Match DOMException error names
11
+ Fix relative-time date casts
12
+ Add a11y regression tests
13
+
14
+ 4.2.0
15
+
16
+ Restore menu focus on select
17
+ Guard menu pointer focus-steal
18
+ Add menu keyboard focus ring
19
+ Conditional dialog aria labelling
20
+ Reentrant overlay scroll lock
21
+ Name popover dialog
22
+ Slider name and form
23
+ Fix dead slider classes
24
+ Add a11y regression tests
25
+
3
26
  4.1.0
4
27
 
5
28
  Add menu/select typeahead
@@ -43,7 +43,7 @@ function isLocalStorageAvailable() {
43
43
  return true;
44
44
  }
45
45
  catch (e) {
46
- console.warn('localStorage not available:', e.message);
46
+ console.warn('localStorage not available:', e instanceof Error ? e.message : String(e));
47
47
  return false;
48
48
  }
49
49
  }
@@ -58,7 +58,7 @@ function safeLSSetItem(key, value) {
58
58
  return true;
59
59
  }
60
60
  catch (error) {
61
- console.error('localStorage setItem error:', error.message);
61
+ console.error('localStorage setItem error:', error instanceof Error ? error.message : String(error));
62
62
  return false;
63
63
  }
64
64
  }
@@ -69,7 +69,7 @@ function safeLSGetItem(key) {
69
69
  return localStorage.getItem(key);
70
70
  }
71
71
  catch (error) {
72
- console.error('localStorage getItem error:', error.message);
72
+ console.error('localStorage getItem error:', error instanceof Error ? error.message : String(error));
73
73
  return null;
74
74
  }
75
75
  }
@@ -81,7 +81,7 @@ function safeLSRemoveItem(key) {
81
81
  return true;
82
82
  }
83
83
  catch (error) {
84
- console.error('localStorage removeItem error:', error.message);
84
+ console.error('localStorage removeItem error:', error instanceof Error ? error.message : String(error));
85
85
  return false;
86
86
  }
87
87
  }
@@ -99,7 +99,7 @@ export function ContextProvider({ children, constants }) {
99
99
  return JSON.parse(storedUser);
100
100
  }
101
101
  catch (e) {
102
- console.error('Error parsing user data:', e.message);
102
+ console.error('Error parsing user data:', e instanceof Error ? e.message : String(e));
103
103
  return null;
104
104
  }
105
105
  };
@@ -126,7 +126,7 @@ export function ContextProvider({ children, constants }) {
126
126
  }
127
127
  }
128
128
  catch (error) {
129
- console.error('Error setting user:', error.message);
129
+ console.error('Error setting user:', error instanceof Error ? error.message : String(error));
130
130
  }
131
131
  return { ...state, user: action.payload };
132
132
  }
@@ -21,7 +21,7 @@ function isLocalStorageAvailable() {
21
21
  return true;
22
22
  }
23
23
  catch (e) {
24
- console.warn('localStorage not available (private mode or quota exceeded):', e.message);
24
+ console.warn('localStorage not available (private mode or quota exceeded):', e instanceof Error ? e.message : String(e));
25
25
  _localStorageAvailable = false;
26
26
  return false;
27
27
  }
@@ -37,11 +37,13 @@ function safeSetItem(key, value) {
37
37
  return true;
38
38
  }
39
39
  catch (error) {
40
- if (error.name === 'QuotaExceededError') {
40
+ // DOMException (browser storage/abort errors) does not extend Error in
41
+ // the browser, so match either to read `.name` safely without a cast.
42
+ if ((error instanceof DOMException || error instanceof Error) && error.name === 'QuotaExceededError') {
41
43
  console.error('localStorage quota exceeded - data not persisted');
42
44
  }
43
45
  else {
44
- console.error('localStorage setItem error:', error.message);
46
+ console.error('localStorage setItem error:', error instanceof Error ? error.message : String(error));
45
47
  }
46
48
  return false;
47
49
  }
@@ -53,7 +55,7 @@ function safeGetItem(key) {
53
55
  return localStorage.getItem(key);
54
56
  }
55
57
  catch (error) {
56
- console.error('localStorage getItem error:', error.message);
58
+ console.error('localStorage getItem error:', error instanceof Error ? error.message : String(error));
57
59
  return null;
58
60
  }
59
61
  }
@@ -65,7 +67,7 @@ function safeRemoveItem(key) {
65
67
  return true;
66
68
  }
67
69
  catch (error) {
68
- console.error('localStorage removeItem error:', error.message);
70
+ console.error('localStorage removeItem error:', error instanceof Error ? error.message : String(error));
69
71
  return false;
70
72
  }
71
73
  }
@@ -523,7 +525,7 @@ export async function showUpgradeSheet(upgradeSheetRef) {
523
525
  (!user?.subscription?.expires || user?.subscription?.expires > Math.floor(Date.now() / 1000));
524
526
  }
525
527
  catch (error) {
526
- console.error('Error parsing user data from storage:', error.message);
528
+ console.error('Error parsing user data from storage:', error instanceof Error ? error.message : String(error));
527
529
  subscriber = false;
528
530
  }
529
531
  }
@@ -580,7 +582,7 @@ export function timestampToString(input, format = "DOB") {
580
582
  return date.toLocaleString();
581
583
  case "ago": {
582
584
  const now = new Date();
583
- const diffInSeconds = Math.floor((now - date) / 1000);
585
+ const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
584
586
  const minute = 60, hour = minute * 60, day = hour * 24;
585
587
  const week = day * 7, month = day * 30, year = day * 365;
586
588
  if (diffInSeconds < minute)
@@ -703,10 +705,12 @@ export async function apiRequest(endpoint, options = {}) {
703
705
  });
704
706
  }
705
707
  catch (error) {
706
- if (error.name === 'AbortError') {
708
+ // fetch() aborts throw a DOMException, which does not extend Error in
709
+ // the browser — match either so abort detection still works.
710
+ if ((error instanceof DOMException || error instanceof Error) && error.name === 'AbortError') {
707
711
  throw error; // Re-throw abort errors for useListData to handle
708
712
  }
709
- throw new Error(`Network error: ${error.message}`);
713
+ throw new Error(`Network error: ${error instanceof Error ? error.message : String(error)}`);
710
714
  }
711
715
  finally {
712
716
  clearTimeout(timeoutId);
@@ -787,7 +791,7 @@ export async function apiRequest(endpoint, options = {}) {
787
791
  return await response.json();
788
792
  }
789
793
  catch (error) {
790
- throw new Error(`Invalid JSON response from server: ${error.message}`);
794
+ throw new Error(`Invalid JSON response from server: ${error instanceof Error ? error.message : String(error)}`);
791
795
  }
792
796
  }
793
797
  /**
@@ -861,10 +865,10 @@ export function useListData(endpoint, sortFn = null) {
861
865
  setError(null);
862
866
  }
863
867
  catch (err) {
864
- // Ignore abort errors
865
- if (err.name === 'AbortError')
868
+ // Ignore abort errors (DOMException in the browser, not an Error)
869
+ if ((err instanceof DOMException || err instanceof Error) && err.name === 'AbortError')
866
870
  return;
867
- setError(err.message);
871
+ setError(err instanceof Error ? err.message : String(err));
868
872
  }
869
873
  finally {
870
874
  setLoading(false);
@@ -899,7 +903,7 @@ export function useForm(initialValues, onSubmit) {
899
903
  setValues(initialValues);
900
904
  }
901
905
  catch (err) {
902
- setError(err.message);
906
+ setError(err instanceof Error ? err.message : String(err));
903
907
  }
904
908
  finally {
905
909
  setSubmitting(false);
@@ -5,6 +5,8 @@ import { cn } from "../shadcn/lib/utils.js";
5
5
  import { Button } from "./button.js";
6
6
  import { Slot, resolveRender } from "./slot.js";
7
7
  import { useControllableState } from "./use-controllable-state.js";
8
+ import { useDialogLabelling } from "./use-labelling.js";
9
+ import { useScrollLock } from "./use-scroll-lock.js";
8
10
  const AlertDialogContext = React.createContext(null);
9
11
  function useAlertDialog() {
10
12
  const ctx = React.useContext(AlertDialogContext);
@@ -20,9 +22,17 @@ function AlertDialog({ open, defaultOpen = false, onOpenChange, children, }) {
20
22
  defaultValue: defaultOpen,
21
23
  onChange: onOpenChange,
22
24
  });
23
- const titleId = React.useId();
24
- const descriptionId = React.useId();
25
- return (_jsx(AlertDialogContext.Provider, { value: { open: isOpen, setOpen, titleId, descriptionId }, children: children }));
25
+ const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy, } = useDialogLabelling();
26
+ return (_jsx(AlertDialogContext.Provider, { value: {
27
+ open: isOpen,
28
+ setOpen,
29
+ titleId,
30
+ descriptionId,
31
+ registerTitle,
32
+ registerDescription,
33
+ labelledBy,
34
+ describedBy,
35
+ }, children: children }));
26
36
  }
27
37
  function AlertDialogTrigger({ className, asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
28
38
  const { open, setOpen } = useAlertDialog();
@@ -41,7 +51,7 @@ function AlertDialogOverlay() {
41
51
  return null;
42
52
  }
43
53
  function AlertDialogContent({ className, size = "default", children, ...props }) {
44
- const { open, setOpen, titleId, descriptionId } = useAlertDialog();
54
+ const { open, setOpen, labelledBy, describedBy } = useAlertDialog();
45
55
  const ref = React.useRef(null);
46
56
  React.useEffect(() => {
47
57
  const node = ref.current;
@@ -52,16 +62,10 @@ function AlertDialogContent({ className, size = "default", children, ...props })
52
62
  else if (!open && node.open)
53
63
  node.close();
54
64
  }, [open]);
55
- React.useEffect(() => {
56
- if (!open)
57
- return;
58
- const prev = document.body.style.overflow;
59
- document.body.style.overflow = "hidden";
60
- return () => {
61
- document.body.style.overflow = prev;
62
- };
63
- }, [open]);
64
- return (_jsx("dialog", { ref: ref, role: "alertdialog", "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "alert-dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
65
+ // Lock background scroll while open via the shared reentrant helper so stacked
66
+ // overlays don't unlock each other when one closes.
67
+ useScrollLock(open);
68
+ return (_jsx("dialog", { ref: ref, role: "alertdialog", "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "alert-dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
65
69
  e.preventDefault();
66
70
  setOpen(false);
67
71
  }, onClose: () => setOpen(false), className: "m-0 grid max-h-none max-w-none place-items-center bg-transparent p-4 backdrop:bg-black/10 backdrop:duration-100 supports-backdrop-filter:backdrop:backdrop-blur-xs open:fixed open:inset-0 open:size-full", children: open ? (_jsx("div", { "data-slot": "alert-dialog-content", "data-size": size, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, className: cn("group/alert-dialog-content relative grid w-full gap-6 rounded-xl bg-background p-6 text-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className), ...props, children: children })) : null }));
@@ -76,11 +80,15 @@ function AlertDialogMedia({ className, ...props }) {
76
80
  return (_jsx("div", { "data-slot": "alert-dialog-media", className: cn("mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8", className), ...props }));
77
81
  }
78
82
  function AlertDialogTitle({ className, ...props }) {
79
- const { titleId } = useAlertDialog();
83
+ const { titleId, registerTitle } = useAlertDialog();
84
+ // Signal that a title exists so the content wires `aria-labelledby` to it.
85
+ React.useEffect(registerTitle, [registerTitle]);
80
86
  return (_jsx("h2", { id: titleId, "data-slot": "alert-dialog-title", className: cn("text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className), ...props }));
81
87
  }
82
88
  function AlertDialogDescription({ className, ...props }) {
83
- const { descriptionId } = useAlertDialog();
89
+ const { descriptionId, registerDescription } = useAlertDialog();
90
+ // Signal that a description exists so the content wires `aria-describedby`.
91
+ React.useEffect(registerDescription, [registerDescription]);
84
92
  return (_jsx("p", { id: descriptionId, "data-slot": "alert-dialog-description", className: cn("text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", className), ...props }));
85
93
  }
86
94
  function AlertDialogAction({ className, onClick, ...props }) {
@@ -1,7 +1,9 @@
1
1
  import * as React from "react";
2
- export interface CheckboxProps extends Omit<React.ComponentProps<"button">, "onChange" | "value"> {
3
- checked?: boolean;
4
- defaultChecked?: boolean;
2
+ /** Checked state of a checkbox: a real boolean, or the tri-state "mixed" value. */
3
+ type CheckedState = boolean | "indeterminate";
4
+ export interface CheckboxProps extends Omit<React.ComponentProps<"button">, "onChange" | "value" | "checked" | "defaultChecked"> {
5
+ checked?: CheckedState;
6
+ defaultChecked?: CheckedState;
5
7
  onCheckedChange?: (checked: boolean) => void;
6
8
  /** When set, a hidden checkbox of this name mirrors the state for form posts. */
7
9
  name?: string;
@@ -9,9 +11,10 @@ export interface CheckboxProps extends Omit<React.ComponentProps<"button">, "onC
9
11
  }
10
12
  /**
11
13
  * Self-contained checkbox: a `role="checkbox"` button with controlled/
12
- * uncontrolled checked state. Emits `data-checked`/`data-unchecked`/
13
- * `data-disabled` so the shared classes apply. Pass `name` to include a
14
- * hidden checkbox for forms.
14
+ * uncontrolled checked state. Supports a tri-state `"indeterminate"` (mixed)
15
+ * value for incoming controlled/default state. Emits `data-checked`/
16
+ * `data-unchecked`/`data-indeterminate`/`data-disabled` so the shared classes
17
+ * apply. Pass `name` to include a hidden checkbox for forms.
15
18
  */
16
19
  declare function Checkbox({ className, checked, defaultChecked, onCheckedChange, disabled, name, value, onClick, ...props }: CheckboxProps): React.JSX.Element;
17
20
  export { Checkbox };
@@ -2,24 +2,36 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { cn } from "../shadcn/lib/utils.js";
5
- import { CheckIcon } from "../icons/index.js";
5
+ import { CheckIcon, MinusIcon } from "../icons/index.js";
6
6
  import { useControllableState } from "./use-controllable-state.js";
7
7
  /**
8
8
  * Self-contained checkbox: a `role="checkbox"` button with controlled/
9
- * uncontrolled checked state. Emits `data-checked`/`data-unchecked`/
10
- * `data-disabled` so the shared classes apply. Pass `name` to include a
11
- * hidden checkbox for forms.
9
+ * uncontrolled checked state. Supports a tri-state `"indeterminate"` (mixed)
10
+ * value for incoming controlled/default state. Emits `data-checked`/
11
+ * `data-unchecked`/`data-indeterminate`/`data-disabled` so the shared classes
12
+ * apply. Pass `name` to include a hidden checkbox for forms.
12
13
  */
13
14
  function Checkbox({ className, checked, defaultChecked = false, onCheckedChange, disabled, name, value = "on", onClick, ...props }) {
15
+ // The hook tracks the full tri-state so an incoming "indeterminate" survives;
16
+ // onCheckedChange is fired separately in onClick where the value is always a
17
+ // boolean, keeping the public callback signature unchanged.
14
18
  const [on, setOn] = useControllableState({
15
19
  value: checked,
16
20
  defaultValue: defaultChecked,
17
- onChange: onCheckedChange,
18
21
  });
19
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", role: "checkbox", "aria-checked": on, disabled: disabled, "data-slot": "checkbox", "data-state": on ? "checked" : "unchecked", "data-checked": on ? "" : undefined, "data-unchecked": on ? undefined : "", "data-disabled": disabled ? "" : undefined, className: cn("cursor-pointer peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none group-has-disabled/field:opacity-50 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) => {
22
+ const isIndeterminate = on === "indeterminate";
23
+ // a11y: a tri-state checkbox must report "mixed" so assistive tech announces
24
+ // the partial selection instead of a plain checked/unchecked state.
25
+ const ariaChecked = isIndeterminate ? "mixed" : on;
26
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", role: "checkbox", "aria-checked": ariaChecked, disabled: disabled, "data-slot": "checkbox", "data-state": isIndeterminate ? "indeterminate" : on ? "checked" : "unchecked", "data-checked": on === true || isIndeterminate ? "" : undefined, "data-unchecked": on === false ? "" : undefined, "data-indeterminate": isIndeterminate ? "" : undefined, "data-disabled": disabled ? "" : undefined, className: cn("cursor-pointer peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none group-has-disabled/field:opacity-50 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) => {
20
27
  onClick?.(e);
21
- if (!e.defaultPrevented)
22
- setOn(!on);
23
- }, ...props, children: on ? (_jsx("span", { "data-slot": "checkbox-indicator", "data-checked": "", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(CheckIcon, {}) })) : null }), name ? (_jsx("input", { type: "checkbox", "aria-hidden": true, tabIndex: -1, name: name, value: value, checked: on, disabled: disabled, readOnly: true, className: "sr-only" })) : null] }));
28
+ if (e.defaultPrevented)
29
+ return;
30
+ // A user click always resolves to a real boolean: indeterminate and
31
+ // unchecked both become checked, checked becomes unchecked.
32
+ const next = on === true ? false : true;
33
+ setOn(next);
34
+ onCheckedChange?.(next);
35
+ }, ...props, children: on === true ? (_jsx("span", { "data-slot": "checkbox-indicator", "data-checked": "", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(CheckIcon, {}) })) : isIndeterminate ? (_jsx("span", { "data-slot": "checkbox-indicator", "data-indeterminate": "", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(MinusIcon, {}) })) : null }), name ? (_jsx("input", { type: "checkbox", "aria-hidden": true, tabIndex: -1, name: name, value: value, checked: on === true, disabled: disabled, readOnly: true, className: "sr-only" })) : null] }));
24
36
  }
25
37
  export { Checkbox };
@@ -7,6 +7,7 @@ import { Portal } from "./portal.js";
7
7
  import { useFloating } from "./use-floating.js";
8
8
  import { usePresence } from "./use-presence.js";
9
9
  import { useTypeahead } from "./use-typeahead.js";
10
+ import { usePointerMoved } from "./use-pointer-moved.js";
10
11
  import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
11
12
  const MenuContext = React.createContext(null);
12
13
  function useMenu() {
@@ -65,6 +66,10 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
65
66
  const [mounted, presenceRef] = usePresence(open);
66
67
  const containerRef = React.useRef(null);
67
68
  const { onTypeaheadKeyDown } = useTypeahead();
69
+ // Pointer-move focus follows the cursor, but keyboard arrow nav scrolls the
70
+ // list and fires a synthetic pointermove at the same coordinates — guard it
71
+ // so scrolling doesn't yank focus off the keyboard target.
72
+ const hasPointerMoved = usePointerMoved();
68
73
  // Position the menu at the cursor, clamped into the viewport once the
69
74
  // popup has measurable dimensions. availableHeight caps the menu so long
70
75
  // lists scroll inside the viewport instead of overflowing it.
@@ -166,6 +171,8 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
166
171
  ["--transform-origin"]: "top left",
167
172
  ["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
168
173
  }, onPointerMove: (e) => {
174
+ if (!hasPointerMoved(e))
175
+ return;
169
176
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
170
177
  if (item && item !== document.activeElement)
171
178
  item.focus();
@@ -216,7 +223,10 @@ function ContextMenuLabel({ className, inset, ...props }) {
216
223
  }
217
224
  function ContextMenuItem({ className, inset, variant = "default", disabled, onSelect, onClick, ...props }) {
218
225
  const { setOpen } = useMenu();
219
- return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "context-menu-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn("group/context-menu-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive", className), onClick: (e) => {
226
+ return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "context-menu-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn(
227
+ // focus-visible ring gives keyboard roving focus a visible indicator
228
+ // (outline-hidden removes the native one); pointer focus stays ring-free.
229
+ "group/context-menu-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive", className), onClick: (e) => {
220
230
  if (disabled)
221
231
  return;
222
232
  onClick?.(e);
@@ -228,7 +238,9 @@ function ContextMenuItem({ className, inset, variant = "default", disabled, onSe
228
238
  }
229
239
  const RadioGroupContext = React.createContext({});
230
240
  function ContextMenuCheckboxItem({ className, children, checked, onCheckedChange, disabled, inset, onClick, ...props }) {
231
- return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-checkbox-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn("relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
241
+ return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-checkbox-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
242
+ // focus-visible ring gives keyboard roving focus a visible indicator.
243
+ "relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
232
244
  if (disabled)
233
245
  return;
234
246
  onClick?.(e);
@@ -242,7 +254,9 @@ function ContextMenuRadioGroup({ value, onValueChange, ...props }) {
242
254
  function ContextMenuRadioItem({ className, children, value, disabled, inset, onClick, ...props }) {
243
255
  const { value: groupValue, onValueChange } = React.useContext(RadioGroupContext);
244
256
  const checked = groupValue === value;
245
- return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-radio-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn("relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
257
+ return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-radio-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
258
+ // focus-visible ring gives keyboard roving focus a visible indicator.
259
+ "relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
246
260
  if (disabled)
247
261
  return;
248
262
  onClick?.(e);
@@ -266,11 +280,14 @@ function useSub() {
266
280
  function ContextMenuSub({ defaultOpen = false, children, }) {
267
281
  const [open, setOpen] = React.useState(defaultOpen);
268
282
  const triggerRef = React.useRef(null);
269
- return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef }, children: children }));
283
+ const subContentId = React.useId();
284
+ return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef, subContentId }, children: children }));
270
285
  }
271
286
  function ContextMenuSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, ...props }) {
272
- const { open, setOpen, triggerRef } = useSub();
273
- return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "data-slot": "context-menu-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn("flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onMouseEnter: (e) => {
287
+ const { open, setOpen, triggerRef, subContentId } = useSub();
288
+ return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? subContentId : undefined, "data-slot": "context-menu-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn(
289
+ // focus-visible ring gives keyboard roving focus a visible indicator.
290
+ "flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onMouseEnter: (e) => {
274
291
  onMouseEnter?.(e);
275
292
  setOpen(true);
276
293
  }, onClick: (e) => {
@@ -288,7 +305,7 @@ function ContextMenuSubTrigger({ className, inset, children, onClick, onKeyDown,
288
305
  }, ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
289
306
  }
290
307
  function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
291
- const { open, setOpen, triggerRef } = useSub();
308
+ const { open, setOpen, triggerRef, subContentId } = useSub();
292
309
  const { layers } = useMenu();
293
310
  const [mounted, presenceRef] = usePresence(open);
294
311
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
@@ -330,7 +347,10 @@ function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
330
347
  }, [open]);
331
348
  if (!mounted)
332
349
  return null;
333
- return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), role: "menu", tabIndex: -1, "data-slot": "context-menu-sub-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? "right", style: {
350
+ return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef),
351
+ // Matches the SubTrigger's aria-controls target so the trigger->submenu
352
+ // relationship is exposed to assistive tech.
353
+ id: subContentId, role: "menu", tabIndex: -1, "data-slot": "context-menu-sub-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? "right", style: {
334
354
  position: "fixed",
335
355
  left: pos?.x ?? 0,
336
356
  top: pos?.y ?? 0,
package/dist/ui/dialog.js CHANGED
@@ -6,6 +6,8 @@ import { Button } from "./button.js";
6
6
  import { Slot, resolveRender } from "./slot.js";
7
7
  import { XIcon } from "../icons/index.js";
8
8
  import { useControllableState } from "./use-controllable-state.js";
9
+ import { useDialogLabelling } from "./use-labelling.js";
10
+ import { useScrollLock } from "./use-scroll-lock.js";
9
11
  const DialogContext = React.createContext(null);
10
12
  function useDialog() {
11
13
  const ctx = React.useContext(DialogContext);
@@ -24,9 +26,10 @@ function Dialog({ open, defaultOpen = false, onOpenChange, children }) {
24
26
  defaultValue: defaultOpen,
25
27
  onChange: onOpenChange,
26
28
  });
27
- const titleId = React.useId();
28
- const descriptionId = React.useId();
29
- return (_jsx(DialogContext.Provider, { value: { open: isOpen, setOpen, titleId, descriptionId }, children: children }));
29
+ // Track which labelling parts actually render so DialogContent can reference
30
+ // them only when present, avoiding a dangling aria-labelledby/-describedby IDREF.
31
+ const labelling = useDialogLabelling();
32
+ return (_jsx(DialogContext.Provider, { value: { open: isOpen, setOpen, ...labelling }, children: children }));
30
33
  }
31
34
  function DialogTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
32
35
  const { open, setOpen } = useDialog();
@@ -57,7 +60,7 @@ function DialogOverlay() {
57
60
  return null;
58
61
  }
59
62
  function DialogContent({ className, children, showCloseButton = true, ...props }) {
60
- const { open, setOpen, titleId, descriptionId } = useDialog();
63
+ const { open, setOpen, labelledBy, describedBy } = useDialog();
61
64
  const ref = React.useRef(null);
62
65
  const pointerDownOnBackdrop = React.useRef(false);
63
66
  React.useEffect(() => {
@@ -69,17 +72,11 @@ function DialogContent({ className, children, showCloseButton = true, ...props }
69
72
  else if (!open && node.open)
70
73
  node.close();
71
74
  }, [open]);
72
- // Lock background scroll while open.
73
- React.useEffect(() => {
74
- if (!open)
75
- return;
76
- const prev = document.body.style.overflow;
77
- document.body.style.overflow = "hidden";
78
- return () => {
79
- document.body.style.overflow = prev;
80
- };
81
- }, [open]);
82
- return (_jsx("dialog", { ref: ref, "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
75
+ // Reentrant, reference-counted scroll lock so stacked overlays don't clobber
76
+ // each other's overflow restore (an inner close would otherwise unlock scroll
77
+ // while an outer dialog is still open).
78
+ useScrollLock(open);
79
+ return (_jsx("dialog", { ref: ref, "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
83
80
  e.preventDefault();
84
81
  setOpen(false);
85
82
  }, onClose: () => setOpen(false), onPointerDown: (e) => {
@@ -99,11 +96,15 @@ function DialogFooter({ className, showCloseButton = false, children, ...props }
99
96
  return (_jsxs("div", { "data-slot": "dialog-footer", className: cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className), ...props, children: [children, showCloseButton && (_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "outline", children: "Close" }) }))] }));
100
97
  }
101
98
  function DialogTitle({ className, ...props }) {
102
- const { titleId } = useDialog();
99
+ const { titleId, registerTitle } = useDialog();
100
+ // Register presence so the container wires aria-labelledby only when a title exists.
101
+ React.useEffect(registerTitle, [registerTitle]);
103
102
  return (_jsx("h2", { id: titleId, "data-slot": "dialog-title", className: cn("leading-none font-medium", className), ...props }));
104
103
  }
105
104
  function DialogDescription({ className, ...props }) {
106
- const { descriptionId } = useDialog();
105
+ const { descriptionId, registerDescription } = useDialog();
106
+ // Register presence so the container wires aria-describedby only when a description exists.
107
+ React.useEffect(registerDescription, [registerDescription]);
107
108
  return (_jsx("p", { id: descriptionId, "data-slot": "dialog-description", className: cn("text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", className), ...props }));
108
109
  }
109
110
  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
package/dist/ui/drawer.js CHANGED
@@ -4,7 +4,9 @@ import * as React from "react";
4
4
  import { cn } from "../shadcn/lib/utils.js";
5
5
  import { Slot, mergeRefs, resolveRender } from "./slot.js";
6
6
  import { useControllableState } from "./use-controllable-state.js";
7
+ import { useDialogLabelling } from "./use-labelling.js";
7
8
  import { usePresence } from "./use-presence.js";
9
+ import { useScrollLock } from "./use-scroll-lock.js";
8
10
  // Drag thresholds and animation timing ported from vaul (MIT — Emil Kowalski).
9
11
  // https://github.com/emilkowalski/vaul/blob/main/src/constants.ts
10
12
  const VELOCITY_THRESHOLD = 0.4;
@@ -51,9 +53,17 @@ function Drawer({ open, defaultOpen = false, onOpenChange, children }) {
51
53
  defaultValue: defaultOpen,
52
54
  onChange: onOpenChange,
53
55
  });
54
- const titleId = React.useId();
55
- const descriptionId = React.useId();
56
- return (_jsx(DrawerContext.Provider, { value: { open: isOpen, setOpen, titleId, descriptionId }, children: children }));
56
+ const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy } = useDialogLabelling();
57
+ return (_jsx(DrawerContext.Provider, { value: {
58
+ open: isOpen,
59
+ setOpen,
60
+ titleId,
61
+ descriptionId,
62
+ registerTitle,
63
+ registerDescription,
64
+ labelledBy,
65
+ describedBy,
66
+ }, children: children }));
57
67
  }
58
68
  function DrawerTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
59
69
  const { open, setOpen } = useDrawer();
@@ -84,7 +94,7 @@ function DrawerOverlay() {
84
94
  return null;
85
95
  }
86
96
  function DrawerContent({ className, children, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, ...props }) {
87
- const { open, setOpen, titleId, descriptionId } = useDrawer();
97
+ const { open, setOpen, labelledBy, describedBy } = useDrawer();
88
98
  const ref = React.useRef(null);
89
99
  const pointerDownOnBackdrop = React.useRef(false);
90
100
  const [mounted, presenceRef] = usePresence(open);
@@ -103,15 +113,11 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
103
113
  else if (!mounted && node.open)
104
114
  node.close();
105
115
  }, [mounted]);
106
- React.useEffect(() => {
107
- if (!mounted)
108
- return;
109
- const prev = document.body.style.overflow;
110
- document.body.style.overflow = "hidden";
111
- return () => {
112
- document.body.style.overflow = prev;
113
- };
114
- }, [mounted]);
116
+ // Reentrant, reference-counted body scroll lock: when drawers stack with other
117
+ // overlays, the inline overflow effect would let an inner overlay's cleanup
118
+ // unlock scroll while an outer overlay is still open. useScrollLock shares one
119
+ // lock so only the last overlay to close restores the original overflow.
120
+ useScrollLock(mounted);
115
121
  // Clear any drag-leftover inline transform whenever we (re)open.
116
122
  React.useEffect(() => {
117
123
  if (open && ref.current) {
@@ -207,7 +213,7 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
207
213
  };
208
214
  if (!mounted)
209
215
  return null;
210
- return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "drawer-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
216
+ return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "drawer-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
211
217
  e.preventDefault();
212
218
  setOpen(false);
213
219
  }, onClose: () => setOpen(false), onClick: (e) => {
@@ -233,11 +239,17 @@ function DrawerFooter({ className, ...props }) {
233
239
  return (_jsx("div", { "data-slot": "drawer-footer", className: cn("mt-auto flex flex-col gap-2 p-4", className), ...props }));
234
240
  }
235
241
  function DrawerTitle({ className, ...props }) {
236
- const { titleId } = useDrawer();
242
+ const { titleId, registerTitle } = useDrawer();
243
+ // Register on mount so the container knows a title exists and can wire
244
+ // aria-labelledby; the returned cleanup unregisters on unmount.
245
+ React.useEffect(registerTitle, [registerTitle]);
237
246
  return (_jsx("h2", { id: titleId, "data-slot": "drawer-title", className: cn("font-medium text-foreground", className), ...props }));
238
247
  }
239
248
  function DrawerDescription({ className, ...props }) {
240
- const { descriptionId } = useDrawer();
249
+ const { descriptionId, registerDescription } = useDrawer();
250
+ // Register on mount so the container knows a description exists and can wire
251
+ // aria-describedby; the returned cleanup unregisters on unmount.
252
+ React.useEffect(registerDescription, [registerDescription]);
241
253
  return (_jsx("p", { id: descriptionId, "data-slot": "drawer-description", className: cn("text-sm text-muted-foreground", className), ...props }));
242
254
  }
243
255
  export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, };