@pienter/ui 0.3.0 → 0.7.1

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.
Files changed (109) hide show
  1. package/CHANGELOG.md +258 -0
  2. package/CONVENTIONS.md +342 -385
  3. package/README.md +33 -18
  4. package/components/display/record-details/RecordDetails.vue +61 -0
  5. package/components/display/record-details/record-details.css +37 -0
  6. package/components/display/record-details/types.ts +8 -0
  7. package/components/feedback/toast/toast.css +1 -1
  8. package/components/form/block-editor/BlockEditor.vue +454 -0
  9. package/components/form/block-editor/block-editor.css +149 -0
  10. package/components/form/block-editor/types.ts +15 -0
  11. package/components/form/combobox/Combobox.vue +42 -72
  12. package/components/form/combobox/combobox.css +1 -1
  13. package/components/form/form/Form.vue +1 -2
  14. package/components/form/label/label.css +1 -1
  15. package/components/form/number-field/NumberField.vue +1 -2
  16. package/components/form/number-field/number-field.css +1 -1
  17. package/components/form/radio-group/RadioGroup.vue +2 -5
  18. package/components/form/record-form/RecordFields.vue +128 -0
  19. package/components/form/record-form/RecordForm.vue +116 -0
  20. package/components/form/record-form/fields.ts +20 -0
  21. package/components/form/record-form/record-form.css +15 -0
  22. package/components/form/record-form/types.ts +28 -0
  23. package/components/form/slider/slider.css +2 -3
  24. package/components/form/tags-input/tags-input.css +1 -2
  25. package/components/form/text-input/text-input.css +2 -0
  26. package/components/form/textarea/textarea.css +1 -1
  27. package/components/layout/accordion/Accordion.vue +6 -27
  28. package/components/layout/collapsible/Collapsible.vue +9 -19
  29. package/components/layout/index/Index.vue +353 -0
  30. package/components/layout/index/index.css +114 -0
  31. package/components/layout/index/useIndex.ts +390 -0
  32. package/components/layout/table/table.css +5 -4
  33. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  34. package/components/navigation/breadcrumb/breadcrumb.css +15 -0
  35. package/components/navigation/sidebar/Sidebar.vue +329 -84
  36. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  37. package/components/navigation/sidebar/context.ts +21 -0
  38. package/components/navigation/sidebar/sidebar.css +346 -78
  39. package/components/navigation/sidebar/types.ts +13 -1
  40. package/components/navigation/tabs/Tabs.vue +6 -0
  41. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  42. package/components/overlay/command/Command.vue +10 -38
  43. package/components/overlay/command/command.css +1 -1
  44. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  45. package/components/overlay/modal/Modal.vue +7 -28
  46. package/components/overlay/popover/Popover.vue +9 -31
  47. package/components/overlay/sheet/Sheet.vue +7 -28
  48. package/components/overlay/tooltip/Tooltip.vue +14 -19
  49. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  50. package/composables/useDisclosure.ts +113 -0
  51. package/composables/useEventListener.ts +16 -0
  52. package/composables/useMenu.ts +212 -0
  53. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  54. package/package.json +16 -40
  55. package/styles/0-settings/colors.css +10 -0
  56. package/styles/0-settings/layout.css +18 -0
  57. package/styles/0-settings/motion.css +2 -2
  58. package/styles/0-settings/spacing.css +3 -1
  59. package/utils/a11y/focus.ts +9 -3
  60. package/utils/cms/index.ts +283 -0
  61. package/utils/cms/schema.json +126 -0
  62. package/utils/navigation/sidebar.ts +97 -0
  63. package/utils/validation/form.ts +6 -9
  64. package/components/action/button/Button.astro +0 -95
  65. package/components/action/button/IconButton.astro +0 -86
  66. package/components/display/avatar/Avatar.astro +0 -17
  67. package/components/display/avatar/AvatarStack.astro +0 -9
  68. package/components/display/badge/Badge.astro +0 -15
  69. package/components/display/empty/Empty.astro +0 -9
  70. package/components/display/icon/Icon.astro +0 -52
  71. package/components/feedback/alert/Alert.astro +0 -52
  72. package/components/feedback/progress/Progress.astro +0 -68
  73. package/components/feedback/skeleton/Skeleton.astro +0 -32
  74. package/components/feedback/spinner/Spinner.astro +0 -25
  75. package/components/feedback/toast/Toast.astro +0 -50
  76. package/components/form/checkbox/Checkbox.astro +0 -79
  77. package/components/form/date-input/DateInput.astro +0 -105
  78. package/components/form/form/Form.astro +0 -106
  79. package/components/form/input-otp/InputOTP.astro +0 -147
  80. package/components/form/label/Label.astro +0 -13
  81. package/components/form/number-field/NumberField.astro +0 -142
  82. package/components/form/radio-group/RadioGroup.astro +0 -105
  83. package/components/form/select/Select.astro +0 -105
  84. package/components/form/slider/Slider.astro +0 -205
  85. package/components/form/switch/Switch.astro +0 -75
  86. package/components/form/tags-input/TagsInput.astro +0 -153
  87. package/components/form/text-input/TextInput.astro +0 -84
  88. package/components/form/textarea/Textarea.astro +0 -86
  89. package/components/layout/card/Card.astro +0 -13
  90. package/components/layout/separator/Separator.astro +0 -31
  91. package/components/layout/table/Table.astro +0 -116
  92. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  93. package/components/navigation/navbar/Navbar.astro +0 -62
  94. package/components/navigation/pagination/PaginationFooter.astro +0 -24
  95. package/components/navigation/sidebar/Sidebar.astro +0 -132
  96. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  97. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  98. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  99. package/components/overlay/modal/Modal.astro +0 -66
  100. package/utils/controllers/disclosure.ts +0 -117
  101. package/utils/controllers/form.ts +0 -524
  102. package/utils/controllers/index.ts +0 -39
  103. package/utils/controllers/menu.ts +0 -255
  104. package/utils/controllers/number-field.ts +0 -103
  105. package/utils/controllers/otp.ts +0 -252
  106. package/utils/controllers/sidebar.ts +0 -610
  107. package/utils/controllers/slider.ts +0 -336
  108. package/utils/controllers/tags-input.ts +0 -255
  109. package/utils/controllers/toast.ts +0 -426
@@ -1,22 +1,30 @@
1
- import { lockScroll } from '../dom/scroll-lock.js';
1
+ import {
2
+ type MaybeRefOrGetter,
3
+ type Ref,
4
+ onBeforeUnmount,
5
+ onMounted,
6
+ toValue,
7
+ watch,
8
+ } from 'vue';
9
+ import { lockScroll } from '../utils/dom/scroll-lock.js';
2
10
 
3
11
  /**
4
- * Imperative control surface returned by `mountDialog`. The controller
5
- * owns the dialog's runtime state (open/closed, scroll lock) and lets the
6
- * native `<dialog>` element handle top-layer rendering, focus trapping,
7
- * and Escape dismissal. Frameworks (Vue, Astro consumer scripts) act as
8
- * the trigger source they call `open()` / `close()` and watch incoming
9
- * prop changes but they never own state directly. This avoids the dual
10
- * state-machine problem where framework state and controller state desync.
12
+ * Handle returned by `useDialog`. The composable owns the dialog's
13
+ * runtime side-effects (scroll lock, focus override, `data-state`
14
+ * reflection) and lets the native `<dialog>` element handle top-layer
15
+ * rendering, focus trapping, and Escape dismissal. The component's
16
+ * reactive prop stays the source of truth for the open/closed boolean
17
+ * the composable never closes on its own; it reports close intent via
18
+ * `onClose` and waits for the prop to change. This avoids the dual
19
+ * state-machine problem where component state and DOM state desync.
11
20
  */
12
- export interface DialogControl {
21
+ export interface DialogHandle {
13
22
  open(): void;
14
23
  close(): void;
15
24
  isOpen(): boolean;
16
- teardown(): void;
17
25
  }
18
26
 
19
- export interface DialogConfig {
27
+ export interface UseDialogOptions {
20
28
  /**
21
29
  * ARIA role assigned to the `<dialog>` element. The browser's default
22
30
  * for `<dialog>` is `'dialog'`; only override (e.g. for AlertDialog)
@@ -24,16 +32,16 @@ export interface DialogConfig {
24
32
  */
25
33
  role?: 'dialog' | 'alertdialog';
26
34
  /**
27
- * When `true`, the controller suppresses the default close behavior on
35
+ * When `true`, the composable suppresses the default close behavior on
28
36
  * Escape (by calling `event.preventDefault()` on the `cancel` event)
29
37
  * and ignores backdrop clicks. Used by AlertDialog. Default: `false`.
30
38
  */
31
- persistent?: boolean;
39
+ persistent?: MaybeRefOrGetter<boolean>;
32
40
  /**
33
41
  * Where focus lands when the dialog opens.
34
42
  *
35
43
  * The native `<dialog>.showModal()` already auto-focuses the first
36
- * focusable child (or the element with `[autofocus]`). This config
44
+ * focusable child (or the element with `[autofocus]`). This option
37
45
  * lets callers override that default:
38
46
  * - `'first'` (default): defer to the browser's native behavior.
39
47
  * - `'cancel'`: focus the element marked `[data-dialog-cancel]`
@@ -45,95 +53,71 @@ export interface DialogConfig {
45
53
  * it ALWAYS wins over the configured value (consumer override).
46
54
  */
47
55
  initialFocus?: 'first' | 'cancel' | HTMLElement | null;
56
+ /**
57
+ * Controlled open state. When the getter yields a boolean, the dialog
58
+ * follows it: `true` opens, `false` closes, `undefined` is ignored.
59
+ * The initial value is applied on mount.
60
+ */
61
+ open?: () => boolean | undefined;
62
+ /** Runs just before the dialog opens, on every open path. */
63
+ onBeforeOpen?: () => void;
48
64
  /**
49
65
  * Called when the user requests close (Escape key, backdrop click).
50
- * The controller does NOT auto-close in this case — the framework
51
- * reflects the close intent back through its prop/v-model and calls
52
- * `close()` itself, keeping framework state authoritative for the
53
- * open/closed boolean.
66
+ * The composable does NOT auto-close in this case — the component
67
+ * reflects the close intent back through its prop/v-model, and the
68
+ * controlled `open` getter (or an explicit `close()` call) performs
69
+ * the actual close.
54
70
  */
55
71
  onClose?: () => void;
56
72
  }
57
73
 
58
74
  /**
59
- * Mount the dialog controller on `el`, which MUST be a `<dialog>`
60
- * element. The native element provides top-layer rendering, focus
75
+ * Dialog behavior for a `<dialog>` element rendered by the calling
76
+ * component. The native element provides top-layer rendering, focus
61
77
  * trapping, Escape dismissal (via the `cancel` event), and the
62
- * `::backdrop` pseudo-element; this controller layers on body-scroll
78
+ * `::backdrop` pseudo-element; this composable layers on body-scroll
63
79
  * lock, backdrop-click dismissal, persistent suppression, an
64
80
  * `initialFocus` override, and `data-state` reflection so the existing
65
- * CSS conventions keep working.
66
- *
67
- * Lifecycle:
68
- * 1. On mount: optionally set `role` (AlertDialog uses `'alertdialog'`),
69
- * wire `cancel` and `click` listeners.
70
- * 2. `open()`: capture trigger, call `el.showModal()` (native top-layer
71
- * + focus trap), set `data-state="open"`, lock scroll, apply
72
- * `initialFocus` override after a frame.
73
- * 3. `close()`: call `el.close()` (native focus restoration to trigger),
74
- * set `data-state="closed"`, unlock scroll. A backup focus restore
75
- * runs only when the browser fails to restore (e.g., the trigger
76
- * was removed from the DOM mid-open).
77
- * 4. `teardown()`: ensures close ran, then removes listeners.
78
- *
79
- * The controller does NOT decide when to close — `onClose` is invoked
80
- * on user-requested close (Escape, backdrop click). The caller is
81
- * responsible for reflecting that into framework state and calling
82
- * `close()`. The framework's reactive prop is the source of truth for
83
- * open/closed; the controller is the source of truth for runtime
84
- * side-effects (focus override, scroll lock).
81
+ * CSS conventions keep working. Listeners are wired on mount and
82
+ * removed before unmount.
85
83
  */
86
- export function mountDialog(
87
- el: HTMLDialogElement,
88
- config: DialogConfig = {},
89
- ): DialogControl {
90
- const onClose = config.onClose;
91
- const persistent = config.persistent ?? false;
92
-
93
- // Optional role override — AlertDialog uses `'alertdialog'`. The
94
- // browser's default for `<dialog>` is already `'dialog'`, so we only
95
- // setAttribute when the caller asks for something different.
96
- if (config.role && config.role !== 'dialog') {
97
- el.setAttribute('role', config.role);
98
- }
99
-
100
- // Initial state.
101
- el.setAttribute('data-state', 'closed');
102
-
84
+ export function useDialog(
85
+ elRef: Ref<HTMLDialogElement | null>,
86
+ options: UseDialogOptions = {},
87
+ ): DialogHandle {
103
88
  // Per-open transient state.
104
89
  let triggerEl: HTMLElement | null = null;
105
90
  let unlockScrollFn: (() => void) | null = null;
106
91
 
92
+ const isPersistent = () => toValue(options.persistent) ?? false;
93
+
107
94
  // The native `cancel` event fires on Escape press. Browser default:
108
95
  // closes the dialog. For persistent dialogs, suppress the close.
109
96
  function handleCancel(event: Event): void {
110
- if (persistent) {
97
+ if (isPersistent()) {
111
98
  event.preventDefault();
112
99
  return;
113
100
  }
114
- onClose?.();
101
+ options.onClose?.();
115
102
  }
116
103
 
117
104
  // Backdrop click — `<dialog>` IS the backdrop element. Clicks on its
118
105
  // children bubble through with `event.target` set to the inner content,
119
- // so a target equal to `el` means the click landed on the backdrop.
106
+ // so a target equal to the dialog means the click landed on the backdrop.
120
107
  function handleBackdropClick(event: MouseEvent): void {
121
- if (persistent) return;
122
- if (event.target !== el) return;
123
- onClose?.();
108
+ if (isPersistent()) return;
109
+ if (event.target !== elRef.value) return;
110
+ options.onClose?.();
124
111
  }
125
112
 
126
- el.addEventListener('cancel', handleCancel);
127
- el.addEventListener('click', handleBackdropClick);
128
-
129
- function resolveInitialFocus(): HTMLElement | null {
113
+ function resolveInitialFocus(el: HTMLDialogElement): HTMLElement | null {
130
114
  // Consumer override always wins.
131
115
  const override = el.querySelector<HTMLElement>(
132
116
  '[data-dialog-initial-focus]',
133
117
  );
134
118
  if (override) return override;
135
119
 
136
- const target = config.initialFocus ?? 'first';
120
+ const target = options.initialFocus ?? 'first';
137
121
  if (target === null) return null;
138
122
  if (target instanceof HTMLElement) return target;
139
123
  if (target === 'cancel') {
@@ -144,7 +128,10 @@ export function mountDialog(
144
128
  }
145
129
 
146
130
  function open(): void {
147
- if (el.open) return; // idempotent
131
+ const el = elRef.value;
132
+ if (!el || el.open) return; // idempotent
133
+
134
+ options.onBeforeOpen?.();
148
135
 
149
136
  // Capture the element that had focus before the dialog opened so
150
137
  // we can restore it on close if the browser fails to do so.
@@ -163,13 +150,14 @@ export function mountDialog(
163
150
  // child by default — only override when the consumer asked for it.
164
151
  requestAnimationFrame(() => {
165
152
  if (!el.open) return; // guard against rapid open→close
166
- const target = resolveInitialFocus();
153
+ const target = resolveInitialFocus(el);
167
154
  if (target) target.focus();
168
155
  });
169
156
  }
170
157
 
171
158
  function close(): void {
172
- if (!el.open) return; // idempotent
159
+ const el = elRef.value;
160
+ if (!el || !el.open) return; // idempotent
173
161
 
174
162
  el.close(); // native focus restoration to the trigger
175
163
  el.setAttribute('data-state', 'closed');
@@ -178,8 +166,8 @@ export function mountDialog(
178
166
 
179
167
  // Backup: if the browser failed to restore focus (e.g. the trigger
180
168
  // was removed from the DOM during the open, or the page lost focus),
181
- // restore manually. We check for activeElement === body which is
182
- // the browser's "no specific element focused" state.
169
+ // restore manually. activeElement === body is the browser's
170
+ // "no specific element focused" state.
183
171
  const trigger = triggerEl;
184
172
  triggerEl = null;
185
173
  if (
@@ -192,14 +180,38 @@ export function mountDialog(
192
180
  }
193
181
 
194
182
  function isOpen(): boolean {
195
- return el.open;
183
+ return elRef.value?.open ?? false;
196
184
  }
197
185
 
198
- function teardown(): void {
186
+ onMounted(() => {
187
+ const el = elRef.value;
188
+ if (!el) return;
189
+ // The browser's default role for `<dialog>` is already `'dialog'`,
190
+ // so only setAttribute when the caller asks for something different.
191
+ if (options.role && options.role !== 'dialog') {
192
+ el.setAttribute('role', options.role);
193
+ }
194
+ el.setAttribute('data-state', 'closed');
195
+ el.addEventListener('cancel', handleCancel);
196
+ el.addEventListener('click', handleBackdropClick);
197
+ if (options.open && toValue(options.open)) open();
198
+ });
199
+
200
+ if (options.open) {
201
+ watch(options.open, (value) => {
202
+ if (!elRef.value || value === undefined) return;
203
+ if (value) open();
204
+ else close();
205
+ });
206
+ }
207
+
208
+ onBeforeUnmount(() => {
209
+ const el = elRef.value;
210
+ if (!el) return;
199
211
  if (el.open) close();
200
212
  el.removeEventListener('cancel', handleCancel);
201
213
  el.removeEventListener('click', handleBackdropClick);
202
- }
214
+ });
203
215
 
204
- return { open, close, isOpen, teardown };
216
+ return { open, close, isOpen };
205
217
  }
@@ -0,0 +1,113 @@
1
+ import {
2
+ type MaybeRefOrGetter,
3
+ type Ref,
4
+ onBeforeUnmount,
5
+ onMounted,
6
+ toValue,
7
+ } from 'vue';
8
+
9
+ /**
10
+ * Disclosure behavior — shared by Accordion and Collapsible per the
11
+ * sharing mapping in `CONVENTIONS.md`.
12
+ *
13
+ * Built on the **native HTML `<details>` / `<summary>` element pair**
14
+ * per the locked "Use native APIs where applicable" rule. The browser
15
+ * already handles expand/collapse on summary click, the `[open]`
16
+ * attribute, Enter/Space activation, and focus on the summary rather
17
+ * than the panel. What the browser does NOT provide and the composable
18
+ * layers on top:
19
+ *
20
+ * - **Single-open coordination** for Accordion mode — when one
21
+ * `<details>` opens, sibling `<details>` elements within the same
22
+ * root close. Native `<details>` has no built-in mutual exclusion
23
+ * (the `name` attribute exists in some browsers but is not reliable
24
+ * cross-browser in 2026).
25
+ * - A **callback** so the component can react to toggles without each
26
+ * `<details>` wiring its own `@toggle` listener.
27
+ *
28
+ * The root may be either a single `<details>` (Collapsible) or a
29
+ * wrapper containing multiple `<details>` children (Accordion).
30
+ */
31
+ export interface UseDisclosureOptions {
32
+ /**
33
+ * When `true`, opening any **top-level** `<details>` within the
34
+ * root closes all sibling top-level `<details>` elements
35
+ * (Accordion single-open mode). A "top-level" `<details>` here
36
+ * means one whose nearest ancestor `<details>` lies outside the
37
+ * root — i.e., a disclosure that belongs to THIS root, not a
38
+ * nested disclosure inside an item's panel content.
39
+ *
40
+ * When `false`, multiple disclosures can be open simultaneously
41
+ * (Collapsible / Accordion multi-open mode). Read reactively —
42
+ * a getter or ref takes effect on the next toggle. Default: `false`.
43
+ */
44
+ singleOpen?: MaybeRefOrGetter<boolean>;
45
+ /**
46
+ * Called whenever a `<details>` within the root toggles. Receives
47
+ * the toggled element and its new open state, for reflecting the
48
+ * change back into reactive state (e.g. `update:open`). The
49
+ * composable does NOT track open state itself — the DOM is the
50
+ * source of truth via `el.open`.
51
+ */
52
+ onToggle?: (target: HTMLDetailsElement, open: boolean) => void;
53
+ }
54
+
55
+ /**
56
+ * Wire disclosure behavior on the element behind `elRef` for the
57
+ * component's lifetime. A single capturing `toggle` listener at the
58
+ * root covers any number of `<details>` descendants without
59
+ * per-element wiring.
60
+ */
61
+ export function useDisclosure(
62
+ elRef: Ref<HTMLElement | null>,
63
+ options: UseDisclosureOptions = {},
64
+ ): void {
65
+ /**
66
+ * A `<details>` is "top-level" relative to the root when no other
67
+ * `<details>` lies between it and the root in the ancestor chain.
68
+ * Only top-level disclosures participate in single-open
69
+ * coordination — nested disclosures are part of the consumer's
70
+ * panel content and must not be closed by the outer root.
71
+ */
72
+ function isTopLevel(details: HTMLDetailsElement): boolean {
73
+ const el = elRef.value;
74
+ const parentDetails = details.parentElement?.closest('details') ?? null;
75
+ if (!parentDetails) return true;
76
+ // If the parent `<details>` is outside the root, treat
77
+ // `details` as top-level for THIS root.
78
+ return !el?.contains(parentDetails);
79
+ }
80
+
81
+ function handleToggle(event: Event): void {
82
+ const el = elRef.value;
83
+ const target = event.target;
84
+ if (!el || !(target instanceof HTMLDetailsElement)) return;
85
+ if (!el.contains(target)) return;
86
+
87
+ const singleOpen = toValue(options.singleOpen) ?? false;
88
+ if (singleOpen && target.open && isTopLevel(target)) {
89
+ // Close every other top-level `<details>` rooted here.
90
+ const allDetails =
91
+ el.querySelectorAll<HTMLDetailsElement>('details');
92
+ allDetails.forEach((other) => {
93
+ if (other === target) return;
94
+ if (!isTopLevel(other)) return;
95
+ if (other.open) other.open = false;
96
+ });
97
+ }
98
+
99
+ options.onToggle?.(target, target.open);
100
+ }
101
+
102
+ // `toggle` does not bubble in the standards sense — but a capturing
103
+ // listener fires for any descendant `<details>` toggle. Use capture
104
+ // phase so a single root listener covers every `<details>` within
105
+ // the scope.
106
+ onMounted(() => {
107
+ elRef.value?.addEventListener('toggle', handleToggle, true);
108
+ });
109
+
110
+ onBeforeUnmount(() => {
111
+ elRef.value?.removeEventListener('toggle', handleToggle, true);
112
+ });
113
+ }
@@ -0,0 +1,16 @@
1
+ import { onBeforeUnmount, onMounted } from 'vue';
2
+
3
+ /**
4
+ * Bind a `window` listener for the lifetime of the component.
5
+ *
6
+ * Only for targets a template cannot reach. `window` is resolved inside
7
+ * `onMounted`, so a server-rendered component never touches it.
8
+ */
9
+ export function useWindowListener<K extends keyof WindowEventMap>(
10
+ type: K,
11
+ listener: (event: WindowEventMap[K]) => void,
12
+ options?: AddEventListenerOptions,
13
+ ): void {
14
+ onMounted(() => window.addEventListener(type, listener, options));
15
+ onBeforeUnmount(() => window.removeEventListener(type, listener, options));
16
+ }
@@ -0,0 +1,212 @@
1
+ import { type Ref, onBeforeUnmount, onMounted } from 'vue';
2
+
3
+ /**
4
+ * Menu keyboard navigation for `role="menu"` surfaces. Pairs with
5
+ * `usePopover` (which owns open/close/positioning) to provide the
6
+ * menu-specific affordances per the WAI-ARIA Menu pattern:
7
+ * ArrowDown/Up cycle through items with wrap, Home/End jump to the
8
+ * ends, Escape closes via the consumer-supplied callback, and a click
9
+ * on a menu item triggers the activate-and-close consumer callback
10
+ * (`onItemActivate`).
11
+ *
12
+ * The items form a roving-tabindex group: only the focused item
13
+ * carries `tabindex="0"`; the rest carry `tabindex="-1"`. This is the
14
+ * canonical menu pattern — Tab moves focus past the menu rather than
15
+ * walking through every item.
16
+ *
17
+ * Item discovery is lazy — the keydown handler re-queries on every
18
+ * navigation event so consumers may add/remove menu items at runtime
19
+ * (e.g., async-loaded items or filter views) without re-wiring.
20
+ *
21
+ * Disabled items (`[disabled]` or `aria-disabled="true"`) are skipped
22
+ * during arrow / Home / End navigation so users do not get "stuck" on
23
+ * a non-actionable item.
24
+ *
25
+ * The composable does NOT decide when to close; consumers wire
26
+ * `onEscape` and `onItemActivate` to their popover handle's `hide()`
27
+ * (the standard dropdown menu UX is auto-close on activate).
28
+ */
29
+ export interface UseMenuOptions {
30
+ /**
31
+ * Selector matching the menu items. Defaults to all three
32
+ * canonical menuitem roles so the same composable serves vanilla
33
+ * menus, checkbox-style menus, and radio-style menus. Override
34
+ * when the consumer needs to filter (e.g. exclude a group label).
35
+ */
36
+ itemSelector?: string;
37
+ /**
38
+ * Called when Escape is pressed inside the menu. The consumer
39
+ * typically forwards to the popover handle's `hide()` so the menu
40
+ * closes and focus returns to the trigger.
41
+ */
42
+ onEscape?: () => void;
43
+ /**
44
+ * Called when a menu item is activated by pointer click or by
45
+ * Enter / Space on the focused item. The consumer typically
46
+ * forwards to the popover handle's `hide()` so the menu
47
+ * auto-closes after a selection. Item-specific actions (the
48
+ * consumer's `@click` handler on each `<button role="menuitem">`)
49
+ * run independently and first (DOM events bubble bottom-up); this
50
+ * callback then closes the menu.
51
+ */
52
+ onItemActivate?: (item: HTMLElement) => void;
53
+ }
54
+
55
+ const DEFAULT_ITEM_SELECTOR =
56
+ '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
57
+
58
+ /**
59
+ * Wire menu-style keyboard navigation onto the `role="menu"` container
60
+ * behind `menuRef` for the component's lifetime. Teardown leaves
61
+ * items' tabindex attributes as-is — a remount re-applies correct
62
+ * values, and removing them risks stomping author-supplied tabindex
63
+ * attributes the composable didn't set originally.
64
+ */
65
+ export function useMenu(
66
+ menuRef: Ref<HTMLElement | null>,
67
+ options: UseMenuOptions = {},
68
+ ): void {
69
+ const itemSelector = options.itemSelector ?? DEFAULT_ITEM_SELECTOR;
70
+
71
+ function getItems(): HTMLElement[] {
72
+ const menuEl = menuRef.value;
73
+ if (!menuEl) return [];
74
+ return Array.from(menuEl.querySelectorAll<HTMLElement>(itemSelector));
75
+ }
76
+
77
+ function isDisabled(item: HTMLElement): boolean {
78
+ return (
79
+ item.hasAttribute('disabled') ||
80
+ item.getAttribute('aria-disabled') === 'true'
81
+ );
82
+ }
83
+
84
+ function getEnabledItems(): HTMLElement[] {
85
+ return getItems().filter((it) => !isDisabled(it));
86
+ }
87
+
88
+ function setRovingTabindex(
89
+ activeItem: HTMLElement | undefined,
90
+ items = getItems(),
91
+ ): void {
92
+ for (const item of items) {
93
+ item.setAttribute('tabindex', item === activeItem ? '0' : '-1');
94
+ }
95
+ }
96
+
97
+ function initRovingTabindex(): void {
98
+ const items = getItems();
99
+ setRovingTabindex(
100
+ items.find((item) => !isDisabled(item)),
101
+ items,
102
+ );
103
+ }
104
+
105
+ function focusItem(item: HTMLElement): void {
106
+ // Focusing the current item does not fire focusin.
107
+ if (document.activeElement === item) setRovingTabindex(item);
108
+ else {
109
+ item.setAttribute('tabindex', '0');
110
+ item.focus();
111
+ }
112
+ }
113
+
114
+ function moveFocus(direction: 1 | -1): void {
115
+ const enabled = getEnabledItems();
116
+ if (enabled.length === 0) return;
117
+
118
+ const active = document.activeElement as HTMLElement | null;
119
+ const currentIdx = active ? enabled.indexOf(active) : -1;
120
+
121
+ let nextIdx: number;
122
+ if (currentIdx === -1) {
123
+ // Focus is not on any enabled item (could be on the menu
124
+ // container itself, or a disabled item). Land on the first
125
+ // for ArrowDown, last for ArrowUp.
126
+ nextIdx = direction === 1 ? 0 : enabled.length - 1;
127
+ } else {
128
+ nextIdx =
129
+ (currentIdx + direction + enabled.length) % enabled.length;
130
+ }
131
+
132
+ focusItem(enabled[nextIdx]!);
133
+ }
134
+
135
+ function focusFirst(): void {
136
+ const enabled = getEnabledItems();
137
+ if (enabled.length > 0) focusItem(enabled[0]!);
138
+ }
139
+
140
+ function focusLast(): void {
141
+ const enabled = getEnabledItems();
142
+ if (enabled.length > 0) focusItem(enabled[enabled.length - 1]!);
143
+ }
144
+
145
+ function onKeydown(event: KeyboardEvent): void {
146
+ switch (event.key) {
147
+ case 'ArrowDown':
148
+ event.preventDefault();
149
+ moveFocus(1);
150
+ break;
151
+ case 'ArrowUp':
152
+ event.preventDefault();
153
+ moveFocus(-1);
154
+ break;
155
+ case 'Home':
156
+ event.preventDefault();
157
+ focusFirst();
158
+ break;
159
+ case 'End':
160
+ event.preventDefault();
161
+ focusLast();
162
+ break;
163
+ case 'Escape':
164
+ // The native Popover API also closes on Escape via
165
+ // `popover="auto"` light-dismiss; calling `onEscape`
166
+ // here lets consumers using `popover="manual"` (or
167
+ // future variants) hook the same key. No
168
+ // preventDefault — the browser's light-dismiss should
169
+ // still run for `auto` popovers.
170
+ options.onEscape?.();
171
+ break;
172
+ default:
173
+ break;
174
+ }
175
+ }
176
+
177
+ function onClick(event: MouseEvent): void {
178
+ const menuEl = menuRef.value;
179
+ const target = event.target as HTMLElement | null;
180
+ if (!menuEl || !target) return;
181
+ const item = target.closest<HTMLElement>(itemSelector);
182
+ if (!item || !menuEl.contains(item)) return;
183
+ if (isDisabled(item)) return;
184
+ options.onItemActivate?.(item);
185
+ }
186
+
187
+ function onFocusIn(event: FocusEvent): void {
188
+ const menuEl = menuRef.value;
189
+ const target = event.target as HTMLElement | null;
190
+ if (!menuEl || !target) return;
191
+ const item = target.closest<HTMLElement>(itemSelector);
192
+ if (!item || !menuEl.contains(item) || isDisabled(item)) return;
193
+ setRovingTabindex(item);
194
+ }
195
+
196
+ onMounted(() => {
197
+ const menuEl = menuRef.value;
198
+ if (!menuEl) return;
199
+ initRovingTabindex();
200
+ menuEl.addEventListener('keydown', onKeydown);
201
+ menuEl.addEventListener('click', onClick);
202
+ menuEl.addEventListener('focusin', onFocusIn);
203
+ });
204
+
205
+ onBeforeUnmount(() => {
206
+ const menuEl = menuRef.value;
207
+ if (!menuEl) return;
208
+ menuEl.removeEventListener('keydown', onKeydown);
209
+ menuEl.removeEventListener('click', onClick);
210
+ menuEl.removeEventListener('focusin', onFocusIn);
211
+ });
212
+ }