@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.
- package/CHANGELOG.md +258 -0
- package/CONVENTIONS.md +342 -385
- package/README.md +33 -18
- package/components/display/record-details/RecordDetails.vue +61 -0
- package/components/display/record-details/record-details.css +37 -0
- package/components/display/record-details/types.ts +8 -0
- package/components/feedback/toast/toast.css +1 -1
- package/components/form/block-editor/BlockEditor.vue +454 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/combobox/Combobox.vue +42 -72
- 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 -2
- package/components/form/number-field/number-field.css +1 -1
- package/components/form/radio-group/RadioGroup.vue +2 -5
- package/components/form/record-form/RecordFields.vue +128 -0
- package/components/form/record-form/RecordForm.vue +116 -0
- package/components/form/record-form/fields.ts +20 -0
- package/components/form/record-form/record-form.css +15 -0
- package/components/form/record-form/types.ts +28 -0
- package/components/form/slider/slider.css +2 -3
- package/components/form/tags-input/tags-input.css +1 -2
- package/components/form/text-input/text-input.css +2 -0
- 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/index/Index.vue +353 -0
- package/components/layout/index/index.css +114 -0
- package/components/layout/index/useIndex.ts +390 -0
- package/components/layout/table/table.css +5 -4
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +15 -0
- package/components/navigation/sidebar/Sidebar.vue +329 -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/navigation/tabs/Tabs.vue +6 -0
- 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/composables/useMenu.ts +212 -0
- package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
- package/package.json +16 -40
- package/styles/0-settings/colors.css +10 -0
- 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/a11y/focus.ts +9 -3
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
- 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/pagination/PaginationFooter.astro +0 -24
- 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/menu.ts +0 -255
- 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
|
+
}
|
|
@@ -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
|
+
}
|