@pienter/ui 0.2.0 → 0.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/CONVENTIONS.md +297 -385
  3. package/README.md +3 -18
  4. package/components/feedback/toast/toast.css +1 -1
  5. package/components/form/combobox/Combobox.vue +21 -38
  6. package/components/form/combobox/combobox.css +1 -1
  7. package/components/form/form/Form.vue +1 -2
  8. package/components/form/label/label.css +1 -1
  9. package/components/form/number-field/NumberField.vue +1 -1
  10. package/components/form/number-field/number-field.css +1 -1
  11. package/components/form/radio-group/RadioGroup.vue +2 -5
  12. package/components/form/slider/slider.css +2 -3
  13. package/components/form/tags-input/tags-input.css +1 -2
  14. package/components/form/textarea/textarea.css +1 -1
  15. package/components/layout/accordion/Accordion.vue +6 -27
  16. package/components/layout/collapsible/Collapsible.vue +9 -19
  17. package/components/layout/table/table.css +3 -3
  18. package/components/navigation/pagination/Pagination.vue +1 -1
  19. package/components/navigation/pagination/PaginationFooter.vue +23 -0
  20. package/components/navigation/pagination/pagination.css +128 -29
  21. package/components/navigation/sidebar/Sidebar.vue +326 -84
  22. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  23. package/components/navigation/sidebar/context.ts +21 -0
  24. package/components/navigation/sidebar/sidebar.css +346 -78
  25. package/components/navigation/sidebar/types.ts +13 -1
  26. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  27. package/components/overlay/command/Command.vue +10 -38
  28. package/components/overlay/command/command.css +1 -1
  29. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  30. package/components/overlay/modal/Modal.vue +7 -28
  31. package/components/overlay/popover/Popover.vue +9 -31
  32. package/components/overlay/sheet/Sheet.vue +7 -28
  33. package/components/overlay/tooltip/Tooltip.vue +14 -19
  34. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  35. package/composables/useDisclosure.ts +113 -0
  36. package/composables/useEventListener.ts +16 -0
  37. package/{utils/controllers/menu.ts → composables/useMenu.ts} +66 -102
  38. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  39. package/package.json +6 -38
  40. package/styles/0-settings/colors.css +8 -1
  41. package/styles/0-settings/layout.css +18 -0
  42. package/styles/0-settings/motion.css +2 -2
  43. package/styles/0-settings/spacing.css +3 -1
  44. package/utils/navigation/sidebar.ts +97 -0
  45. package/utils/validation/form.ts +6 -9
  46. package/components/action/button/Button.astro +0 -95
  47. package/components/action/button/IconButton.astro +0 -86
  48. package/components/display/avatar/Avatar.astro +0 -17
  49. package/components/display/avatar/AvatarStack.astro +0 -9
  50. package/components/display/badge/Badge.astro +0 -15
  51. package/components/display/empty/Empty.astro +0 -9
  52. package/components/display/icon/Icon.astro +0 -52
  53. package/components/feedback/alert/Alert.astro +0 -52
  54. package/components/feedback/progress/Progress.astro +0 -68
  55. package/components/feedback/skeleton/Skeleton.astro +0 -32
  56. package/components/feedback/spinner/Spinner.astro +0 -25
  57. package/components/feedback/toast/Toast.astro +0 -50
  58. package/components/form/checkbox/Checkbox.astro +0 -79
  59. package/components/form/date-input/DateInput.astro +0 -105
  60. package/components/form/form/Form.astro +0 -106
  61. package/components/form/input-otp/InputOTP.astro +0 -147
  62. package/components/form/label/Label.astro +0 -13
  63. package/components/form/number-field/NumberField.astro +0 -142
  64. package/components/form/radio-group/RadioGroup.astro +0 -105
  65. package/components/form/select/Select.astro +0 -105
  66. package/components/form/slider/Slider.astro +0 -205
  67. package/components/form/switch/Switch.astro +0 -75
  68. package/components/form/tags-input/TagsInput.astro +0 -153
  69. package/components/form/text-input/TextInput.astro +0 -84
  70. package/components/form/textarea/Textarea.astro +0 -86
  71. package/components/layout/card/Card.astro +0 -13
  72. package/components/layout/separator/Separator.astro +0 -31
  73. package/components/layout/table/Table.astro +0 -116
  74. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  75. package/components/navigation/navbar/Navbar.astro +0 -62
  76. package/components/navigation/sidebar/Sidebar.astro +0 -132
  77. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  78. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  79. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  80. package/components/overlay/modal/Modal.astro +0 -66
  81. package/utils/controllers/disclosure.ts +0 -117
  82. package/utils/controllers/form.ts +0 -524
  83. package/utils/controllers/index.ts +0 -39
  84. package/utils/controllers/number-field.ts +0 -103
  85. package/utils/controllers/otp.ts +0 -252
  86. package/utils/controllers/sidebar.ts +0 -610
  87. package/utils/controllers/slider.ts +0 -336
  88. package/utils/controllers/tags-input.ts +0 -255
  89. 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
+ }
@@ -1,63 +1,53 @@
1
+ import { type Ref, onBeforeUnmount, onMounted } from 'vue';
2
+
1
3
  /**
2
- * Menu controller — keyboard navigation for `role="menu"` surfaces.
3
- * Pairs with `mountPopover` (which owns open/close/positioning) to
4
- * provide the menu-specific affordances per the WAI-ARIA Menu
5
- * pattern: ArrowDown/Up cycle through items with wrap, Home/End
6
- * jump to the ends, Escape closes via the consumer-supplied
7
- * callback, and a click on a menu item triggers the
8
- * activate-and-close consumer callback (`onItemActivate`).
9
- *
10
- * The controller treats the items as a roving-tabindex group: only
11
- * the focused item carries `tabindex="0"`; the rest carry
12
- * `tabindex="-1"`. This is the canonical menu pattern — Tab moves
13
- * focus past the menu rather than walking through every item.
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`).
14
11
  *
15
- * Mount on:
16
- * - The menu container element (the element that carries
17
- * `role="menu"`). Items are queried inside the container via
18
- * `itemSelector` (default covers `menuitem`,
19
- * `menuitemcheckbox`, and `menuitemradio` roles).
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.
20
16
  *
21
17
  * Item discovery is lazy — the keydown handler re-queries on every
22
- * navigation event so consumers may add/remove menu items at
23
- * runtime (for e.g., async-loaded items or filter views) without
24
- * remounting the controller.
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.
25
24
  *
26
- * The controller does NOT decide when to close; consumers wire
27
- * `onEscape` and `onItemActivate` to their popover control's
28
- * `hide()` (the standard dropdown menu UX is auto-close on activate).
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).
29
28
  */
30
-
31
- export interface MenuConfig {
32
- /**
33
- * The menu container element with `role="menu"`. Items are
34
- * resolved by querying this element with `itemSelector`.
35
- */
36
- menuEl: HTMLElement;
29
+ export interface UseMenuOptions {
37
30
  /**
38
31
  * Selector matching the menu items. Defaults to all three
39
- * canonical menuitem roles so the same controller serves
40
- * vanilla menus, checkbox-style menus, and radio-style menus.
41
- * Override when the consumer needs to filter (e.g. exclude a
42
- * group label) — but the role-based default is correct for the
43
- * v1 DropdownMenu component.
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).
44
35
  */
45
36
  itemSelector?: string;
46
37
  /**
47
38
  * Called when Escape is pressed inside the menu. The consumer
48
- * typically forwards to `popoverControl.hide()` so the menu
39
+ * typically forwards to the popover handle's `hide()` so the menu
49
40
  * closes and focus returns to the trigger.
50
41
  */
51
42
  onEscape?: () => void;
52
43
  /**
53
44
  * Called when a menu item is activated by pointer click or by
54
45
  * Enter / Space on the focused item. The consumer typically
55
- * forwards to `popoverControl.hide()` so the menu auto-closes
56
- * after a selection — the standard menu UX. Item-specific
57
- * actions (the consumer's `@click` handler on each
58
- * `<button role="menuitem">`) run independently from this
59
- * callback; the consumer's handler runs first, then this
60
- * callback closes the menu.
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.
61
51
  */
62
52
  onItemActivate?: (item: HTMLElement) => void;
63
53
  }
@@ -66,47 +56,21 @@ const DEFAULT_ITEM_SELECTOR =
66
56
  '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
67
57
 
68
58
  /**
69
- * Wire menu-style keyboard navigation onto a `role="menu"`
70
- * container. Returns a teardown function that removes the
71
- * listeners and restores any tabindex attributes the controller
72
- * managed.
73
- *
74
- * Lifecycle:
75
- * 1. On mount: set `tabindex="-1"` on every item (only the
76
- * currently-focused item gets `tabindex="0"` after the menu
77
- * opens and the consumer focuses the first item). The first
78
- * focus event inside the menu promotes that item.
79
- * 2. On `keydown` inside the menu:
80
- * - ArrowDown moves to the next item; wraps to first.
81
- * - ArrowUp moves to the previous item; wraps to last.
82
- * - Home jumps to the first item.
83
- * - End jumps to the last item.
84
- * - Escape calls `onEscape`.
85
- * - Enter / Space on an item calls `onItemActivate(item)` and
86
- * lets the native click behavior of `<button>` items run on
87
- * the same key. (Native `<button>` already triggers click on
88
- * Enter/Space, so we only call `onItemActivate` to close;
89
- * the consumer's click handler runs from the synthetic
90
- * click.)
91
- * 3. On click of an item: call `onItemActivate(item)`. The
92
- * consumer's own click handler runs first (DOM event bubbles
93
- * bottom-up; we listen on the menu container).
94
- * 4. On `focus` inside the menu (capture): re-rove the tabindex
95
- * so only the focused item has `tabindex="0"`.
96
- *
97
- * Cross-cutting:
98
- * - Disabled items (`[disabled]` or `aria-disabled="true"`) are
99
- * skipped during arrow / Home / End navigation. They remain in
100
- * the DOM and can still receive direct clicks (native
101
- * `disabled` already blocks that), but the keyboard path
102
- * skips them so users do not get "stuck" on a non-actionable
103
- * item.
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.
104
64
  */
105
- export function mountMenu(config: MenuConfig): () => void {
106
- const menuEl = config.menuEl;
107
- const itemSelector = config.itemSelector ?? DEFAULT_ITEM_SELECTOR;
65
+ export function useMenu(
66
+ menuRef: Ref<HTMLElement | null>,
67
+ options: UseMenuOptions = {},
68
+ ): void {
69
+ const itemSelector = options.itemSelector ?? DEFAULT_ITEM_SELECTOR;
108
70
 
109
71
  function getItems(): HTMLElement[] {
72
+ const menuEl = menuRef.value;
73
+ if (!menuEl) return [];
110
74
  return Array.from(menuEl.querySelectorAll<HTMLElement>(itemSelector));
111
75
  }
112
76
 
@@ -121,15 +85,12 @@ export function mountMenu(config: MenuConfig): () => void {
121
85
  return getItems().filter((it) => !isDisabled(it));
122
86
  }
123
87
 
124
- // Initial roving tabindex — every item starts at `-1`. The
125
- // first item is promoted to `0` so a Tab into the menu (rare —
88
+ // Initial roving tabindex — every item starts at `-1`. The first
89
+ // enabled item is promoted to `0` so a Tab into the menu (rare —
126
90
  // the trigger usually opens with explicit focus) lands somewhere.
127
91
  function initRovingTabindex(): void {
128
92
  const items = getItems();
129
93
  items.forEach((item, idx) => {
130
- // Don't override an explicit author tabindex on disabled items;
131
- // for active items, the first enabled item gets `0` and the
132
- // rest get `-1`.
133
94
  if (isDisabled(item)) {
134
95
  item.setAttribute('tabindex', '-1');
135
96
  return;
@@ -139,8 +100,6 @@ export function mountMenu(config: MenuConfig): () => void {
139
100
  });
140
101
  }
141
102
 
142
- initRovingTabindex();
143
-
144
103
  function focusItem(item: HTMLElement): void {
145
104
  // Promote the focus target to `tabindex="0"` and demote the
146
105
  // rest to `-1` BEFORE focusing — focus on a `tabindex="-1"`
@@ -206,10 +165,10 @@ export function mountMenu(config: MenuConfig): () => void {
206
165
  // The native Popover API also closes on Escape via
207
166
  // `popover="auto"` light-dismiss; calling `onEscape`
208
167
  // here lets consumers using `popover="manual"` (or
209
- // future variants) hook the same key. We do NOT
168
+ // future variants) hook the same key. No
210
169
  // preventDefault — the browser's light-dismiss should
211
170
  // still run for `auto` popovers.
212
- config.onEscape?.();
171
+ options.onEscape?.();
213
172
  break;
214
173
  default:
215
174
  break;
@@ -217,17 +176,19 @@ export function mountMenu(config: MenuConfig): () => void {
217
176
  }
218
177
 
219
178
  function onClick(event: MouseEvent): void {
179
+ const menuEl = menuRef.value;
220
180
  const target = event.target as HTMLElement | null;
221
- if (!target) return;
181
+ if (!menuEl || !target) return;
222
182
  const item = target.closest<HTMLElement>(itemSelector);
223
183
  if (!item || !menuEl.contains(item)) return;
224
184
  if (isDisabled(item)) return;
225
- config.onItemActivate?.(item);
185
+ options.onItemActivate?.(item);
226
186
  }
227
187
 
228
188
  function onFocusIn(event: FocusEvent): void {
189
+ const menuEl = menuRef.value;
229
190
  const target = event.target as HTMLElement | null;
230
- if (!target) return;
191
+ if (!menuEl || !target) return;
231
192
  const item = target.closest<HTMLElement>(itemSelector);
232
193
  if (!item || !menuEl.contains(item) || isDisabled(item)) return;
233
194
  // Re-rove so only the freshly-focused item carries
@@ -239,17 +200,20 @@ export function mountMenu(config: MenuConfig): () => void {
239
200
  });
240
201
  }
241
202
 
242
- menuEl.addEventListener('keydown', onKeydown);
243
- menuEl.addEventListener('click', onClick);
244
- menuEl.addEventListener('focusin', onFocusIn);
245
-
246
- return function teardown(): void {
203
+ onMounted(() => {
204
+ const menuEl = menuRef.value;
205
+ if (!menuEl) return;
206
+ initRovingTabindex();
207
+ menuEl.addEventListener('keydown', onKeydown);
208
+ menuEl.addEventListener('click', onClick);
209
+ menuEl.addEventListener('focusin', onFocusIn);
210
+ });
211
+
212
+ onBeforeUnmount(() => {
213
+ const menuEl = menuRef.value;
214
+ if (!menuEl) return;
247
215
  menuEl.removeEventListener('keydown', onKeydown);
248
216
  menuEl.removeEventListener('click', onClick);
249
217
  menuEl.removeEventListener('focusin', onFocusIn);
250
- // Leave items' tabindex attributes as-is — if the menu
251
- // remounts, `initRovingTabindex` will re-apply correct values.
252
- // Removing them risks stomping author-supplied tabindex
253
- // attributes the controller didn't set originally.
254
- };
218
+ });
255
219
  }