@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.
- package/CHANGELOG.md +259 -0
- package/CONVENTIONS.md +297 -385
- package/README.md +3 -18
- package/components/feedback/toast/toast.css +1 -1
- package/components/form/combobox/Combobox.vue +21 -38
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/form/Form.vue +1 -2
- package/components/form/label/label.css +1 -1
- package/components/form/number-field/NumberField.vue +1 -1
- package/components/form/number-field/number-field.css +1 -1
- package/components/form/radio-group/RadioGroup.vue +2 -5
- package/components/form/slider/slider.css +2 -3
- package/components/form/tags-input/tags-input.css +1 -2
- package/components/form/textarea/textarea.css +1 -1
- package/components/layout/accordion/Accordion.vue +6 -27
- package/components/layout/collapsible/Collapsible.vue +9 -19
- package/components/layout/table/table.css +3 -3
- package/components/navigation/pagination/Pagination.vue +1 -1
- package/components/navigation/pagination/PaginationFooter.vue +23 -0
- package/components/navigation/pagination/pagination.css +128 -29
- package/components/navigation/sidebar/Sidebar.vue +326 -84
- package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
- package/components/navigation/sidebar/context.ts +21 -0
- package/components/navigation/sidebar/sidebar.css +346 -78
- package/components/navigation/sidebar/types.ts +13 -1
- package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
- package/components/overlay/command/Command.vue +10 -38
- package/components/overlay/command/command.css +1 -1
- package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
- package/components/overlay/modal/Modal.vue +7 -28
- package/components/overlay/popover/Popover.vue +9 -31
- package/components/overlay/sheet/Sheet.vue +7 -28
- package/components/overlay/tooltip/Tooltip.vue +14 -19
- package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
- package/composables/useDisclosure.ts +113 -0
- package/composables/useEventListener.ts +16 -0
- package/{utils/controllers/menu.ts → composables/useMenu.ts} +66 -102
- package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
- package/package.json +6 -38
- package/styles/0-settings/colors.css +8 -1
- package/styles/0-settings/layout.css +18 -0
- package/styles/0-settings/motion.css +2 -2
- package/styles/0-settings/spacing.css +3 -1
- package/utils/navigation/sidebar.ts +97 -0
- package/utils/validation/form.ts +6 -9
- package/components/action/button/Button.astro +0 -95
- package/components/action/button/IconButton.astro +0 -86
- package/components/display/avatar/Avatar.astro +0 -17
- package/components/display/avatar/AvatarStack.astro +0 -9
- package/components/display/badge/Badge.astro +0 -15
- package/components/display/empty/Empty.astro +0 -9
- package/components/display/icon/Icon.astro +0 -52
- package/components/feedback/alert/Alert.astro +0 -52
- package/components/feedback/progress/Progress.astro +0 -68
- package/components/feedback/skeleton/Skeleton.astro +0 -32
- package/components/feedback/spinner/Spinner.astro +0 -25
- package/components/feedback/toast/Toast.astro +0 -50
- package/components/form/checkbox/Checkbox.astro +0 -79
- package/components/form/date-input/DateInput.astro +0 -105
- package/components/form/form/Form.astro +0 -106
- package/components/form/input-otp/InputOTP.astro +0 -147
- package/components/form/label/Label.astro +0 -13
- package/components/form/number-field/NumberField.astro +0 -142
- package/components/form/radio-group/RadioGroup.astro +0 -105
- package/components/form/select/Select.astro +0 -105
- package/components/form/slider/Slider.astro +0 -205
- package/components/form/switch/Switch.astro +0 -75
- package/components/form/tags-input/TagsInput.astro +0 -153
- package/components/form/text-input/TextInput.astro +0 -84
- package/components/form/textarea/Textarea.astro +0 -86
- package/components/layout/card/Card.astro +0 -13
- package/components/layout/separator/Separator.astro +0 -31
- package/components/layout/table/Table.astro +0 -116
- package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
- package/components/navigation/navbar/Navbar.astro +0 -62
- package/components/navigation/sidebar/Sidebar.astro +0 -132
- package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
- package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
- package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
- package/components/overlay/modal/Modal.astro +0 -66
- package/utils/controllers/disclosure.ts +0 -117
- package/utils/controllers/form.ts +0 -524
- package/utils/controllers/index.ts +0 -39
- package/utils/controllers/number-field.ts +0 -103
- package/utils/controllers/otp.ts +0 -252
- package/utils/controllers/sidebar.ts +0 -610
- package/utils/controllers/slider.ts +0 -336
- package/utils/controllers/tags-input.ts +0 -255
- package/utils/controllers/toast.ts +0 -426
|
@@ -1,22 +1,30 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* native `<dialog>` element handle top-layer
|
|
7
|
-
* and Escape dismissal.
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
51
|
-
* reflects the close intent back through its prop/v-model and
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
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
|
-
*
|
|
60
|
-
*
|
|
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
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
):
|
|
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 (
|
|
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
|
|
106
|
+
// so a target equal to the dialog means the click landed on the backdrop.
|
|
120
107
|
function handleBackdropClick(event: MouseEvent): void {
|
|
121
|
-
if (
|
|
122
|
-
if (event.target !==
|
|
123
|
-
onClose?.();
|
|
108
|
+
if (isPersistent()) return;
|
|
109
|
+
if (event.target !== elRef.value) return;
|
|
110
|
+
options.onClose?.();
|
|
124
111
|
}
|
|
125
112
|
|
|
126
|
-
el
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
182
|
-
//
|
|
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
|
|
183
|
+
return elRef.value?.open ?? false;
|
|
196
184
|
}
|
|
197
185
|
|
|
198
|
-
|
|
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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
|
27
|
-
* `onEscape` and `onItemActivate` to their popover
|
|
28
|
-
*
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 `
|
|
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 `
|
|
56
|
-
* after a selection
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* callback
|
|
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
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
}
|