@stevederico/skateboard-ui 4.1.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # CHANGELOG
2
2
 
3
+ 4.5.0
4
+
5
+ Toggle uses resolvedTheme
6
+ ThemeProvider owns dark
7
+ Drop dark-mode stripping
8
+ Slot render child fallback
9
+
10
+ 4.4.0
11
+
12
+ Document a11y releases
13
+ Fix peer-dep naming
14
+
15
+ 4.3.0
16
+
17
+ Submenu aria-controls wiring
18
+ Menu labelled by trigger
19
+ Tooltip dismiss on outside tap
20
+ Checkbox indeterminate mixed state
21
+ Replace catch any narrowing
22
+ Match DOMException error names
23
+ Fix relative-time date casts
24
+ Add a11y regression tests
25
+
26
+ 4.2.0
27
+
28
+ Restore menu focus on select
29
+ Guard menu pointer focus-steal
30
+ Add menu keyboard focus ring
31
+ Conditional dialog aria labelling
32
+ Reentrant overlay scroll lock
33
+ Name popover dialog
34
+ Slider name and form
35
+ Fix dead slider classes
36
+ Add a11y regression tests
37
+
3
38
  4.1.0
4
39
 
5
40
  Add menu/select typeahead
package/README.md CHANGED
@@ -6,6 +6,8 @@ React component library for rapid application development. Built with TypeScript
6
6
 
7
7
  **Self-contained components (v4.0).** Every component is now hand-written with no primitive framework underneath — the previously-vendored `@base-ui/react` bundle (~31k lines) is gone. Floating positioning, focus trapping, dismiss layers, and animations are small in-house hooks; modals use the native `<dialog>` element. The legacy `shadcn/ui/*` import paths still work — they re-export the new `ui/*` components — so existing apps need no changes.
8
8
 
9
+ **Accessibility-hardened (v4.1–4.3).** The self-contained tier was put through three rounds of a11y work: menu/select typeahead, correct keyboard focus management and tab-stops (menus, tabs, radios, popovers, navigation menu), ARIA wiring (`aria-controls`, labelled-by, indeterminate checkbox mixed state, named popover/slider dialogs), scroll-lock and exit-animation fixes, plus matching DOMException error names. Each release adds to a Playwright a11y regression suite that runs `workers: 1`.
10
+
9
11
  **TypeScript with full type declarations.** Source is TypeScript, compiled to plain JavaScript + `.d.ts` in `dist/` (`npm run build`). JavaScript apps consume the compiled JS exactly as before — import paths are unchanged — and TypeScript apps get real types for every export (`SkateboardConstants`, `User`, component props, `VariantProps`, typed icons).
10
12
 
11
13
  **Node.js 24+** is required in the app repo for build tooling and backend (see `engines` in `package.json`). The UI package itself runs in the browser.
@@ -1309,7 +1311,7 @@ All components support dark mode automatically and accept a `className` prop for
1309
1311
  ### Required peer dependencies
1310
1312
  - React 19.1+
1311
1313
  - react-dom 19.1+
1312
- - react-router-dom 7.0+
1314
+ - react-router 7.0+
1313
1315
 
1314
1316
  ### Hard dependencies
1315
1317
 
@@ -5,14 +5,14 @@ import { Sun, Moon } from '../icons/index.js';
5
5
  import { Button } from '../shadcn/ui/button.js';
6
6
  import { cn } from '../shadcn/lib/utils.js';
7
7
  export default function ThemeToggle({ className = "", iconSize = 16, variant = "settings", ...props }) {
8
- const { theme, setTheme } = useTheme();
8
+ const { resolvedTheme, setTheme } = useTheme();
9
9
  const [mounted, setMounted] = React.useState(false);
10
10
  React.useEffect(() => {
11
11
  setMounted(true);
12
12
  }, []);
13
13
  if (!mounted)
14
14
  return null;
15
- const isDarkMode = theme === 'dark';
15
+ const isDarkMode = resolvedTheme === 'dark';
16
16
  const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
17
17
  return (_jsx(Button, { variant: variant === "landing" ? "outline" : "ghost", size: "icon", onClick: toggleTheme, className: cn(className), "aria-label": "Toggle dark mode", ...props, children: isDarkMode
18
18
  ? _jsx(Sun, { size: iconSize })
@@ -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
  }
@@ -321,7 +321,8 @@ export declare function showUpgradeSheet(upgradeSheetRef: RefObject<{
321
321
  */
322
322
  export declare function timestampToString(input: number | Date, format?: string): string | number | Date;
323
323
  /**
324
- * Hook that sets the document title and removes dark mode on non-app pages.
324
+ * Hook that sets the document title on navigation.
325
+ * Dark mode is owned by ThemeProvider — never touch the class here.
325
326
  *
326
327
  * @param {Object} location - react-router location object
327
328
  */
@@ -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)
@@ -655,17 +657,14 @@ export function timestampToString(input, format = "DOB") {
655
657
  }
656
658
  }
657
659
  /**
658
- * Hook that sets the document title and removes dark mode on non-app pages.
660
+ * Hook that sets the document title on navigation.
661
+ * Dark mode is owned by ThemeProvider — never touch the class here.
659
662
  *
660
663
  * @param {Object} location - react-router location object
661
664
  */
662
665
  export function useAppSetup(location) {
663
666
  useEffect(() => {
664
667
  document.title = getConstants().appName;
665
- if (!location.pathname.toLowerCase().includes('app')) {
666
- document.documentElement.classList.remove('dark');
667
- document.body.classList.remove('dark');
668
- }
669
668
  }, [location.pathname]);
670
669
  }
671
670
  // ===== API REQUEST UTILITIES =====
@@ -703,10 +702,12 @@ export async function apiRequest(endpoint, options = {}) {
703
702
  });
704
703
  }
705
704
  catch (error) {
706
- if (error.name === 'AbortError') {
705
+ // fetch() aborts throw a DOMException, which does not extend Error in
706
+ // the browser — match either so abort detection still works.
707
+ if ((error instanceof DOMException || error instanceof Error) && error.name === 'AbortError') {
707
708
  throw error; // Re-throw abort errors for useListData to handle
708
709
  }
709
- throw new Error(`Network error: ${error.message}`);
710
+ throw new Error(`Network error: ${error instanceof Error ? error.message : String(error)}`);
710
711
  }
711
712
  finally {
712
713
  clearTimeout(timeoutId);
@@ -787,7 +788,7 @@ export async function apiRequest(endpoint, options = {}) {
787
788
  return await response.json();
788
789
  }
789
790
  catch (error) {
790
- throw new Error(`Invalid JSON response from server: ${error.message}`);
791
+ throw new Error(`Invalid JSON response from server: ${error instanceof Error ? error.message : String(error)}`);
791
792
  }
792
793
  }
793
794
  /**
@@ -861,10 +862,10 @@ export function useListData(endpoint, sortFn = null) {
861
862
  setError(null);
862
863
  }
863
864
  catch (err) {
864
- // Ignore abort errors
865
- if (err.name === 'AbortError')
865
+ // Ignore abort errors (DOMException in the browser, not an Error)
866
+ if ((err instanceof DOMException || err instanceof Error) && err.name === 'AbortError')
866
867
  return;
867
- setError(err.message);
868
+ setError(err instanceof Error ? err.message : String(err));
868
869
  }
869
870
  finally {
870
871
  setLoading(false);
@@ -899,7 +900,7 @@ export function useForm(initialValues, onSubmit) {
899
900
  setValues(initialValues);
900
901
  }
901
902
  catch (err) {
902
- setError(err.message);
903
+ setError(err instanceof Error ? err.message : String(err));
903
904
  }
904
905
  finally {
905
906
  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, };