@pienter/ui 0.2.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 +64 -0
- package/CONVENTIONS.md +1499 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/components/action/button/Button.astro +95 -0
- package/components/action/button/Button.vue +92 -0
- package/components/action/button/IconButton.astro +86 -0
- package/components/action/button/IconButton.vue +85 -0
- package/components/action/button/button.css +194 -0
- package/components/action/toggle/Toggle.vue +38 -0
- package/components/action/toggle/toggle.css +77 -0
- package/components/action/toggle-group/ToggleGroup.vue +67 -0
- package/components/action/toggle-group/toggle-group.css +78 -0
- package/components/display/avatar/Avatar.astro +17 -0
- package/components/display/avatar/Avatar.vue +30 -0
- package/components/display/avatar/AvatarStack.astro +9 -0
- package/components/display/avatar/AvatarStack.vue +11 -0
- package/components/display/avatar/avatar.css +58 -0
- package/components/display/badge/Badge.astro +15 -0
- package/components/display/badge/Badge.vue +23 -0
- package/components/display/badge/badge.css +56 -0
- package/components/display/empty/Empty.astro +9 -0
- package/components/display/empty/Empty.vue +11 -0
- package/components/display/empty/empty.css +39 -0
- package/components/display/icon/Icon.astro +52 -0
- package/components/display/icon/Icon.vue +57 -0
- package/components/display/icon/icon.css +47 -0
- package/components/feedback/alert/Alert.astro +52 -0
- package/components/feedback/alert/Alert.vue +60 -0
- package/components/feedback/alert/alert.css +78 -0
- package/components/feedback/progress/Progress.astro +68 -0
- package/components/feedback/progress/Progress.vue +82 -0
- package/components/feedback/progress/progress.css +68 -0
- package/components/feedback/skeleton/Skeleton.astro +32 -0
- package/components/feedback/skeleton/Skeleton.vue +39 -0
- package/components/feedback/skeleton/skeleton.css +56 -0
- package/components/feedback/spinner/Spinner.astro +25 -0
- package/components/feedback/spinner/Spinner.vue +36 -0
- package/components/feedback/spinner/spinner.css +91 -0
- package/components/feedback/toast/Toast.astro +50 -0
- package/components/feedback/toast/Toast.vue +74 -0
- package/components/feedback/toast/toast.css +128 -0
- package/components/form/checkbox/Checkbox.astro +79 -0
- package/components/form/checkbox/Checkbox.vue +95 -0
- package/components/form/checkbox/checkbox.css +59 -0
- package/components/form/combobox/Combobox.vue +508 -0
- package/components/form/combobox/combobox.css +110 -0
- package/components/form/date-input/DateInput.astro +105 -0
- package/components/form/date-input/DateInput.vue +121 -0
- package/components/form/date-input/date-input.css +19 -0
- package/components/form/form/Form.astro +106 -0
- package/components/form/form/Form.vue +181 -0
- package/components/form/form/form.css +46 -0
- package/components/form/input-otp/InputOTP.astro +147 -0
- package/components/form/input-otp/InputOTP.vue +209 -0
- package/components/form/input-otp/input-otp.css +52 -0
- package/components/form/label/Label.astro +13 -0
- package/components/form/label/Label.vue +20 -0
- package/components/form/label/label.css +11 -0
- package/components/form/number-field/NumberField.astro +142 -0
- package/components/form/number-field/NumberField.vue +155 -0
- package/components/form/number-field/number-field.css +115 -0
- package/components/form/radio-group/RadioGroup.astro +105 -0
- package/components/form/radio-group/RadioGroup.vue +110 -0
- package/components/form/radio-group/radio-group.css +114 -0
- package/components/form/radio-group/types.ts +14 -0
- package/components/form/select/Segmented.vue +36 -0
- package/components/form/select/Select.astro +105 -0
- package/components/form/select/Select.vue +109 -0
- package/components/form/select/select.css +96 -0
- package/components/form/slider/Slider.astro +205 -0
- package/components/form/slider/Slider.vue +321 -0
- package/components/form/slider/slider.css +115 -0
- package/components/form/switch/Switch.astro +75 -0
- package/components/form/switch/Switch.vue +89 -0
- package/components/form/switch/switch.css +64 -0
- package/components/form/tags-input/TagsInput.astro +153 -0
- package/components/form/tags-input/TagsInput.vue +207 -0
- package/components/form/tags-input/tags-input.css +128 -0
- package/components/form/text-input/TextInput.astro +84 -0
- package/components/form/text-input/TextInput.vue +99 -0
- package/components/form/text-input/text-input.css +165 -0
- package/components/form/textarea/Textarea.astro +86 -0
- package/components/form/textarea/Textarea.vue +102 -0
- package/components/form/textarea/textarea.css +25 -0
- package/components/layout/accordion/Accordion.vue +59 -0
- package/components/layout/accordion/accordion.css +87 -0
- package/components/layout/card/Card.astro +13 -0
- package/components/layout/card/Card.vue +20 -0
- package/components/layout/card/card.css +55 -0
- package/components/layout/collapsible/Collapsible.vue +77 -0
- package/components/layout/collapsible/collapsible.css +76 -0
- package/components/layout/separator/Separator.astro +31 -0
- package/components/layout/separator/Separator.vue +33 -0
- package/components/layout/separator/separator.css +27 -0
- package/components/layout/table/DataTable.vue +127 -0
- package/components/layout/table/Table.astro +116 -0
- package/components/layout/table/Table.vue +146 -0
- package/components/layout/table/TableRow.vue +59 -0
- package/components/layout/table/table.css +201 -0
- package/components/layout/table/types.ts +35 -0
- package/components/layout/table/useTable.ts +7 -0
- package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
- package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
- package/components/navigation/breadcrumb/breadcrumb.css +37 -0
- package/components/navigation/navbar/Navbar.astro +62 -0
- package/components/navigation/navbar/Navbar.vue +50 -0
- package/components/navigation/navbar/navbar.css +77 -0
- package/components/navigation/pagination/Pagination.vue +107 -0
- package/components/navigation/pagination/pagination.css +53 -0
- package/components/navigation/sidebar/Sidebar.astro +132 -0
- package/components/navigation/sidebar/Sidebar.vue +174 -0
- package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
- package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
- package/components/navigation/sidebar/sidebar.css +303 -0
- package/components/navigation/sidebar/types.ts +72 -0
- package/components/navigation/tabs/Tabs.vue +84 -0
- package/components/navigation/tabs/tabs.css +39 -0
- package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
- package/components/overlay/alert-dialog/alert-dialog.css +57 -0
- package/components/overlay/command/Command.vue +356 -0
- package/components/overlay/command/command.css +179 -0
- package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
- package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
- package/components/overlay/modal/Modal.astro +66 -0
- package/components/overlay/modal/Modal.vue +85 -0
- package/components/overlay/modal/modal.css +60 -0
- package/components/overlay/popover/Popover.vue +113 -0
- package/components/overlay/popover/popover.css +53 -0
- package/components/overlay/sheet/Sheet.vue +88 -0
- package/components/overlay/sheet/sheet.css +108 -0
- package/components/overlay/tooltip/Tooltip.vue +210 -0
- package/components/overlay/tooltip/tooltip.css +50 -0
- package/composables/useUrlSort.ts +48 -0
- package/icons/alert-triangle.ts +1 -0
- package/icons/arrow-down.ts +1 -0
- package/icons/arrow-up-down.ts +5 -0
- package/icons/arrow-up.ts +1 -0
- package/icons/bell.ts +1 -0
- package/icons/check.ts +1 -0
- package/icons/chevron-down.ts +1 -0
- package/icons/chevron-left.ts +1 -0
- package/icons/chevron-right.ts +1 -0
- package/icons/chevron-up-down.ts +5 -0
- package/icons/chevron-up.ts +1 -0
- package/icons/circle-alert.ts +1 -0
- package/icons/circle-check.ts +1 -0
- package/icons/clipboard.ts +1 -0
- package/icons/download.ts +1 -0
- package/icons/edit.ts +1 -0
- package/icons/external-link.ts +1 -0
- package/icons/eye.ts +1 -0
- package/icons/file.ts +1 -0
- package/icons/filter.ts +1 -0
- package/icons/folder.ts +1 -0
- package/icons/image.ts +1 -0
- package/icons/inbox.ts +1 -0
- package/icons/index.ts +91 -0
- package/icons/info.ts +1 -0
- package/icons/layers.ts +1 -0
- package/icons/link-2.ts +1 -0
- package/icons/list.ts +1 -0
- package/icons/loader.ts +3 -0
- package/icons/menu.ts +1 -0
- package/icons/more-horizontal.ts +1 -0
- package/icons/more-vertical.ts +1 -0
- package/icons/plus-circle.ts +1 -0
- package/icons/plus.ts +1 -0
- package/icons/save.ts +1 -0
- package/icons/search.ts +1 -0
- package/icons/send.ts +1 -0
- package/icons/settings.ts +1 -0
- package/icons/tool.ts +1 -0
- package/icons/trash-2.ts +1 -0
- package/icons/trash.ts +1 -0
- package/icons/upload-cloud.ts +1 -0
- package/icons/upload.ts +1 -0
- package/icons/x.ts +1 -0
- package/package.json +150 -0
- package/styles/0-settings/colors.css +241 -0
- package/styles/0-settings/index.css +5 -0
- package/styles/0-settings/layout.css +52 -0
- package/styles/0-settings/motion.css +11 -0
- package/styles/0-settings/spacing.css +15 -0
- package/styles/0-settings/typography.css +37 -0
- package/styles/0-utils/index.css +1 -0
- package/styles/1-reset/index.css +1 -0
- package/styles/1-reset/reset.css +26 -0
- package/styles/2-base/base.css +42 -0
- package/styles/2-base/forms.css +23 -0
- package/styles/2-base/index.css +2 -0
- package/styles/3-layout/container.css +57 -0
- package/styles/3-layout/index.css +2 -0
- package/styles/3-layout/section.css +17 -0
- package/styles/5-utilities/accessibility.css +13 -0
- package/styles/5-utilities/index.css +2 -0
- package/styles/5-utilities/text.css +5 -0
- package/styles/main.css +8 -0
- package/styles/styles.d.ts +6 -0
- package/utils/a11y/focus.ts +68 -0
- package/utils/a11y/id.ts +10 -0
- package/utils/a11y/index.ts +9 -0
- package/utils/a11y/keyboard.ts +32 -0
- package/utils/a11y/live-region.ts +36 -0
- package/utils/controllers/dialog.ts +205 -0
- package/utils/controllers/disclosure.ts +117 -0
- package/utils/controllers/form.ts +524 -0
- package/utils/controllers/index.ts +39 -0
- package/utils/controllers/menu.ts +255 -0
- package/utils/controllers/number-field.ts +103 -0
- package/utils/controllers/otp.ts +252 -0
- package/utils/controllers/popover.ts +434 -0
- package/utils/controllers/sidebar.ts +610 -0
- package/utils/controllers/slider.ts +336 -0
- package/utils/controllers/tags-input.ts +255 -0
- package/utils/controllers/toast.ts +426 -0
- package/utils/dom/index.ts +1 -0
- package/utils/dom/scroll-lock.ts +48 -0
- package/utils/index.ts +3 -0
- package/utils/sort/index.ts +3 -0
- package/utils/sort/serialize.ts +19 -0
- package/utils/sort/state.ts +11 -0
- package/utils/sort/types.ts +15 -0
- package/utils/validation/form.ts +93 -0
- package/utils/validation/index.ts +13 -0
- package/utils/validation/rules.ts +31 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
14
|
+
*
|
|
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).
|
|
20
|
+
*
|
|
21
|
+
* 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.
|
|
25
|
+
*
|
|
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).
|
|
29
|
+
*/
|
|
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;
|
|
37
|
+
/**
|
|
38
|
+
* 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.
|
|
44
|
+
*/
|
|
45
|
+
itemSelector?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Called when Escape is pressed inside the menu. The consumer
|
|
48
|
+
* typically forwards to `popoverControl.hide()` so the menu
|
|
49
|
+
* closes and focus returns to the trigger.
|
|
50
|
+
*/
|
|
51
|
+
onEscape?: () => void;
|
|
52
|
+
/**
|
|
53
|
+
* Called when a menu item is activated by pointer click or by
|
|
54
|
+
* 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.
|
|
61
|
+
*/
|
|
62
|
+
onItemActivate?: (item: HTMLElement) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_ITEM_SELECTOR =
|
|
66
|
+
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
|
|
67
|
+
|
|
68
|
+
/**
|
|
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.
|
|
104
|
+
*/
|
|
105
|
+
export function mountMenu(config: MenuConfig): () => void {
|
|
106
|
+
const menuEl = config.menuEl;
|
|
107
|
+
const itemSelector = config.itemSelector ?? DEFAULT_ITEM_SELECTOR;
|
|
108
|
+
|
|
109
|
+
function getItems(): HTMLElement[] {
|
|
110
|
+
return Array.from(menuEl.querySelectorAll<HTMLElement>(itemSelector));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isDisabled(item: HTMLElement): boolean {
|
|
114
|
+
return (
|
|
115
|
+
item.hasAttribute('disabled') ||
|
|
116
|
+
item.getAttribute('aria-disabled') === 'true'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getEnabledItems(): HTMLElement[] {
|
|
121
|
+
return getItems().filter((it) => !isDisabled(it));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Initial roving tabindex — every item starts at `-1`. The
|
|
125
|
+
// first item is promoted to `0` so a Tab into the menu (rare —
|
|
126
|
+
// the trigger usually opens with explicit focus) lands somewhere.
|
|
127
|
+
function initRovingTabindex(): void {
|
|
128
|
+
const items = getItems();
|
|
129
|
+
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
|
+
if (isDisabled(item)) {
|
|
134
|
+
item.setAttribute('tabindex', '-1');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const firstEnabledIdx = items.findIndex((i) => !isDisabled(i));
|
|
138
|
+
item.setAttribute('tabindex', idx === firstEnabledIdx ? '0' : '-1');
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
initRovingTabindex();
|
|
143
|
+
|
|
144
|
+
function focusItem(item: HTMLElement): void {
|
|
145
|
+
// Promote the focus target to `tabindex="0"` and demote the
|
|
146
|
+
// rest to `-1` BEFORE focusing — focus on a `tabindex="-1"`
|
|
147
|
+
// element works fine, but the rove must reflect the new state
|
|
148
|
+
// so a subsequent Tab leaves the menu cleanly.
|
|
149
|
+
const items = getItems();
|
|
150
|
+
items.forEach((it) => {
|
|
151
|
+
it.setAttribute('tabindex', it === item ? '0' : '-1');
|
|
152
|
+
});
|
|
153
|
+
item.focus();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function moveFocus(direction: 1 | -1): void {
|
|
157
|
+
const enabled = getEnabledItems();
|
|
158
|
+
if (enabled.length === 0) return;
|
|
159
|
+
|
|
160
|
+
const active = document.activeElement as HTMLElement | null;
|
|
161
|
+
const currentIdx = active ? enabled.indexOf(active) : -1;
|
|
162
|
+
|
|
163
|
+
let nextIdx: number;
|
|
164
|
+
if (currentIdx === -1) {
|
|
165
|
+
// Focus is not on any enabled item (could be on the menu
|
|
166
|
+
// container itself, or a disabled item). Land on the first
|
|
167
|
+
// for ArrowDown, last for ArrowUp.
|
|
168
|
+
nextIdx = direction === 1 ? 0 : enabled.length - 1;
|
|
169
|
+
} else {
|
|
170
|
+
nextIdx =
|
|
171
|
+
(currentIdx + direction + enabled.length) % enabled.length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
focusItem(enabled[nextIdx]!);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function focusFirst(): void {
|
|
178
|
+
const enabled = getEnabledItems();
|
|
179
|
+
if (enabled.length > 0) focusItem(enabled[0]!);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function focusLast(): void {
|
|
183
|
+
const enabled = getEnabledItems();
|
|
184
|
+
if (enabled.length > 0) focusItem(enabled[enabled.length - 1]!);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
188
|
+
switch (event.key) {
|
|
189
|
+
case 'ArrowDown':
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
moveFocus(1);
|
|
192
|
+
break;
|
|
193
|
+
case 'ArrowUp':
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
moveFocus(-1);
|
|
196
|
+
break;
|
|
197
|
+
case 'Home':
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
focusFirst();
|
|
200
|
+
break;
|
|
201
|
+
case 'End':
|
|
202
|
+
event.preventDefault();
|
|
203
|
+
focusLast();
|
|
204
|
+
break;
|
|
205
|
+
case 'Escape':
|
|
206
|
+
// The native Popover API also closes on Escape via
|
|
207
|
+
// `popover="auto"` light-dismiss; calling `onEscape`
|
|
208
|
+
// here lets consumers using `popover="manual"` (or
|
|
209
|
+
// future variants) hook the same key. We do NOT
|
|
210
|
+
// preventDefault — the browser's light-dismiss should
|
|
211
|
+
// still run for `auto` popovers.
|
|
212
|
+
config.onEscape?.();
|
|
213
|
+
break;
|
|
214
|
+
default:
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function onClick(event: MouseEvent): void {
|
|
220
|
+
const target = event.target as HTMLElement | null;
|
|
221
|
+
if (!target) return;
|
|
222
|
+
const item = target.closest<HTMLElement>(itemSelector);
|
|
223
|
+
if (!item || !menuEl.contains(item)) return;
|
|
224
|
+
if (isDisabled(item)) return;
|
|
225
|
+
config.onItemActivate?.(item);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function onFocusIn(event: FocusEvent): void {
|
|
229
|
+
const target = event.target as HTMLElement | null;
|
|
230
|
+
if (!target) return;
|
|
231
|
+
const item = target.closest<HTMLElement>(itemSelector);
|
|
232
|
+
if (!item || !menuEl.contains(item) || isDisabled(item)) return;
|
|
233
|
+
// Re-rove so only the freshly-focused item carries
|
|
234
|
+
// `tabindex="0"`. This catches focus that didn't go through
|
|
235
|
+
// `focusItem` (e.g., a click that focused an item directly).
|
|
236
|
+
const items = getItems();
|
|
237
|
+
items.forEach((it) => {
|
|
238
|
+
it.setAttribute('tabindex', it === item ? '0' : '-1');
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
menuEl.addEventListener('keydown', onKeydown);
|
|
243
|
+
menuEl.addEventListener('click', onClick);
|
|
244
|
+
menuEl.addEventListener('focusin', onFocusIn);
|
|
245
|
+
|
|
246
|
+
return function teardown(): void {
|
|
247
|
+
menuEl.removeEventListener('keydown', onKeydown);
|
|
248
|
+
menuEl.removeEventListener('click', onClick);
|
|
249
|
+
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
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for `mountNumberField`. The controller wires the
|
|
3
|
+
* decrement / increment step buttons inside a NumberField wrapper to
|
|
4
|
+
* the inner `<input type="number">`. Native arrow-up / arrow-down
|
|
5
|
+
* keypresses on the input are already handled by the browser; this
|
|
6
|
+
* controller only adds button click handling.
|
|
7
|
+
*
|
|
8
|
+
* `onChange` is an optional observer hook fired after each successful
|
|
9
|
+
* step, receiving the resulting `valueAsNumber`. NaN is passed when
|
|
10
|
+
* the input is empty after the step (rare — `stepUp` / `stepDown`
|
|
11
|
+
* seed an empty input from `min` / 0 per the HTML spec).
|
|
12
|
+
*/
|
|
13
|
+
export interface NumberFieldConfig {
|
|
14
|
+
/** Optional callback fired after a step button changes the value. */
|
|
15
|
+
onChange?: (value: number) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mount the NumberField controller on `wrapperEl`, which MUST be the
|
|
20
|
+
* `<div class="v-number-field">` wrapper element containing both the
|
|
21
|
+
* `<input class="v-number-field__input">` and the
|
|
22
|
+
* `[data-direction="decrement"|"increment"]` step buttons.
|
|
23
|
+
*
|
|
24
|
+
* Lifecycle (non-stateful per CONVENTIONS — teardown-only signature,
|
|
25
|
+
* matching `mountForm`):
|
|
26
|
+
* 1. On mount: locate the inner input + step buttons, wire `click`
|
|
27
|
+
* listeners on both buttons.
|
|
28
|
+
* 2. Click on a step button calls native `input.stepDown()` /
|
|
29
|
+
* `stepUp()`, which respects the `min`, `max`, and `step` HTML
|
|
30
|
+
* attributes on the `<input type="number">`. The controller does
|
|
31
|
+
* NOT do its own clamping — the browser owns the math.
|
|
32
|
+
* 3. After mutating the input value, dispatch `input` and `change`
|
|
33
|
+
* events (both bubbling) so Form's `mountForm` controller — and
|
|
34
|
+
* Vue's `v-model` indirectly when the framework wraps the same
|
|
35
|
+
* DOM — observe the change.
|
|
36
|
+
* 4. `teardown()`: removes the click listeners. Returns the same
|
|
37
|
+
* teardown contract as `mountForm`.
|
|
38
|
+
*
|
|
39
|
+
* Mounting on a wrapper without an inner input or step buttons is a
|
|
40
|
+
* graceful no-op: the controller returns a teardown function that
|
|
41
|
+
* does nothing. This mirrors `mountForm`'s defensive behavior and
|
|
42
|
+
* makes hoisted `querySelectorAll` mounting safe to call on pages
|
|
43
|
+
* that include a NumberField partial only sometimes.
|
|
44
|
+
*
|
|
45
|
+
* Native keyboard interactions on the `<input type="number">` are
|
|
46
|
+
* NOT handled by this controller — the browser already provides:
|
|
47
|
+
* - ArrowUp / ArrowDown to step
|
|
48
|
+
* - mouse wheel to step (in some browsers)
|
|
49
|
+
* Page Up / Page Down (large step) and Home / End (jump to min/max)
|
|
50
|
+
* are documented as out-of-scope for v1; see the NumberField audit
|
|
51
|
+
* entry in CONVENTIONS for rationale.
|
|
52
|
+
*/
|
|
53
|
+
export function mountNumberField(
|
|
54
|
+
wrapperEl: HTMLElement,
|
|
55
|
+
config: NumberFieldConfig = {},
|
|
56
|
+
): () => void {
|
|
57
|
+
const input = wrapperEl.querySelector<HTMLInputElement>(
|
|
58
|
+
'input.v-number-field__input',
|
|
59
|
+
);
|
|
60
|
+
if (!input) {
|
|
61
|
+
// Graceful no-op: wrapper is missing the inner input.
|
|
62
|
+
return () => {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const decrementBtn = wrapperEl.querySelector<HTMLButtonElement>(
|
|
66
|
+
'[data-direction="decrement"]',
|
|
67
|
+
);
|
|
68
|
+
const incrementBtn = wrapperEl.querySelector<HTMLButtonElement>(
|
|
69
|
+
'[data-direction="increment"]',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const onChange = config.onChange;
|
|
73
|
+
|
|
74
|
+
function step(direction: 'decrement' | 'increment'): void {
|
|
75
|
+
if (!input || input.disabled) return;
|
|
76
|
+
if (direction === 'decrement') {
|
|
77
|
+
input.stepDown();
|
|
78
|
+
} else {
|
|
79
|
+
input.stepUp();
|
|
80
|
+
}
|
|
81
|
+
// Dispatch native events so Form's controller, Vue's v-model,
|
|
82
|
+
// and any consumer-attached listeners see the change.
|
|
83
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
84
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
85
|
+
onChange?.(input.valueAsNumber);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function handleDecrement(): void {
|
|
89
|
+
step('decrement');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleIncrement(): void {
|
|
93
|
+
step('increment');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
decrementBtn?.addEventListener('click', handleDecrement);
|
|
97
|
+
incrementBtn?.addEventListener('click', handleIncrement);
|
|
98
|
+
|
|
99
|
+
return function teardown(): void {
|
|
100
|
+
decrementBtn?.removeEventListener('click', handleDecrement);
|
|
101
|
+
incrementBtn?.removeEventListener('click', handleIncrement);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for `mountInputOTP`. The controller wires N
|
|
3
|
+
* `<input class="v-input-otp__slot">` single-character inputs inside
|
|
4
|
+
* an InputOTP wrapper for the canonical one-time-code UX:
|
|
5
|
+
* - Type a character matching `pattern` → fill, advance focus.
|
|
6
|
+
* - Backspace on a filled slot → clear it (focus stays).
|
|
7
|
+
* - Backspace on an empty slot → focus + clear the previous slot.
|
|
8
|
+
* - Arrow Left / Right → move focus between slots.
|
|
9
|
+
* - Paste a multi-character string → distribute across slots starting
|
|
10
|
+
* from the focused position; characters that fail the pattern are
|
|
11
|
+
* silently skipped.
|
|
12
|
+
*
|
|
13
|
+
* `onChange` receives the concatenated OTP string after every change.
|
|
14
|
+
* Empty slots are represented as the empty string in the concatenation
|
|
15
|
+
* (so a partial code like `"12 "` reflects two filled slots and two
|
|
16
|
+
* empty), keeping the contract simple for consumers building reactive
|
|
17
|
+
* UIs that need to know when the code is complete (`value.length ===
|
|
18
|
+
* length` AND `!value.includes(' ')` — the controller emits a
|
|
19
|
+
* literal-empty for unfilled slots, NOT a space; see implementation).
|
|
20
|
+
*/
|
|
21
|
+
export interface InputOTPConfig {
|
|
22
|
+
/**
|
|
23
|
+
* Optional regex used to validate each typed / pasted character.
|
|
24
|
+
* Defaults to digits-only (`/^[0-9]$/`). Pass an alphanumeric or
|
|
25
|
+
* uppercase-letter pattern when the OTP code is not a PIN.
|
|
26
|
+
*/
|
|
27
|
+
pattern?: RegExp;
|
|
28
|
+
/**
|
|
29
|
+
* Callback fired on every value change with the concatenated OTP
|
|
30
|
+
* string. Length always equals the slot count; unfilled slots are
|
|
31
|
+
* the empty character so consumers can distinguish "fully entered"
|
|
32
|
+
* from "partial".
|
|
33
|
+
*/
|
|
34
|
+
onChange?: (value: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mount the InputOTP controller on `wrapperEl`, which MUST be the
|
|
39
|
+
* `<div class="v-input-otp">` wrapper containing N
|
|
40
|
+
* `<input class="v-input-otp__slot">` children. The wrapper is the
|
|
41
|
+
* only DOM the controller modifies (slot focus / value); the surrounding
|
|
42
|
+
* `.v-field` scaffold (label, hint, errors) is owned by the framework
|
|
43
|
+
* component.
|
|
44
|
+
*
|
|
45
|
+
* Lifecycle (non-stateful per CONVENTIONS — teardown-only signature):
|
|
46
|
+
* 1. On mount: locate all slot inputs via `.v-input-otp__slot`.
|
|
47
|
+
* 2. Wire `input`, `keydown`, `paste`, and `focus` listeners on each
|
|
48
|
+
* slot. Behaviours:
|
|
49
|
+
* - **input**: validate against `pattern`. Reject (clear the slot)
|
|
50
|
+
* if invalid; advance focus to the next empty slot if valid and
|
|
51
|
+
* not the last.
|
|
52
|
+
* - **keydown** Backspace: if the current slot is empty, focus +
|
|
53
|
+
* clear the previous; if filled, clear it (stay).
|
|
54
|
+
* - **keydown** ArrowLeft / ArrowRight: navigate between slots.
|
|
55
|
+
* - **paste**: distribute pasted characters across slots starting
|
|
56
|
+
* from the focused index. Pattern-failing characters are skipped
|
|
57
|
+
* so a paste of "123-456" with a digit pattern yields "123456".
|
|
58
|
+
* Focus lands on the slot after the last filled one (or the last
|
|
59
|
+
* slot if all were filled).
|
|
60
|
+
* 3. Every value change dispatches `input` and `change` events
|
|
61
|
+
* (bubbling) on the wrapper element, plus calls `onChange` with
|
|
62
|
+
* the joined OTP string. The hidden `<input name>` paired by the
|
|
63
|
+
* framework component receives the same value via `dispatchEvent`
|
|
64
|
+
* on its element when the wrapper emits.
|
|
65
|
+
* 4. `teardown()`: removes all listeners. The slot DOM is left in
|
|
66
|
+
* place; the framework owns markup cleanup.
|
|
67
|
+
*
|
|
68
|
+
* Mounting on a wrapper without slot inputs is a graceful no-op.
|
|
69
|
+
*
|
|
70
|
+
* Per CONVENTIONS, the InputOTP wrapper does NOT use `aria-hidden`
|
|
71
|
+
* or `role="group"` on the slot row — each slot is a native input
|
|
72
|
+
* with its own `aria-label="Digit N of M"`. The form-primitive
|
|
73
|
+
* `.v-field` label still applies to the group as a whole; per-slot
|
|
74
|
+
* labels are screen-reader specific.
|
|
75
|
+
*/
|
|
76
|
+
export function mountInputOTP(
|
|
77
|
+
wrapperEl: HTMLElement,
|
|
78
|
+
config: InputOTPConfig = {},
|
|
79
|
+
): () => void {
|
|
80
|
+
const slots = Array.from(
|
|
81
|
+
wrapperEl.querySelectorAll<HTMLInputElement>('input.v-input-otp__slot'),
|
|
82
|
+
);
|
|
83
|
+
if (slots.length === 0) {
|
|
84
|
+
// Graceful no-op: wrapper is missing slot inputs.
|
|
85
|
+
return () => {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const pattern = config.pattern ?? /^[0-9]$/;
|
|
89
|
+
const onChange = config.onChange;
|
|
90
|
+
|
|
91
|
+
// The hidden input the framework component renders for FormData
|
|
92
|
+
// serialization (named via `:name`). Optional — if absent, the
|
|
93
|
+
// controller still works for client-only state.
|
|
94
|
+
const hiddenInput = wrapperEl.querySelector<HTMLInputElement>(
|
|
95
|
+
'input.v-input-otp__hidden',
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
function readValue(): string {
|
|
99
|
+
return slots
|
|
100
|
+
.map((s) => (s.value.length > 0 ? s.value[0] : ''))
|
|
101
|
+
.join('');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function notifyChange(): void {
|
|
105
|
+
const value = readValue();
|
|
106
|
+
if (hiddenInput) {
|
|
107
|
+
hiddenInput.value = value;
|
|
108
|
+
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
109
|
+
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
110
|
+
}
|
|
111
|
+
onChange?.(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function focusSlot(index: number): void {
|
|
115
|
+
const target = slots[index];
|
|
116
|
+
if (target) {
|
|
117
|
+
target.focus();
|
|
118
|
+
// Select the slot's content so a typed digit overwrites cleanly.
|
|
119
|
+
target.select();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findThisIndex(target: EventTarget | null): number {
|
|
124
|
+
if (!(target instanceof HTMLInputElement)) return -1;
|
|
125
|
+
return slots.indexOf(target);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleInput(event: Event): void {
|
|
129
|
+
const idx = findThisIndex(event.target);
|
|
130
|
+
if (idx === -1) return;
|
|
131
|
+
const slot = slots[idx];
|
|
132
|
+
if (!slot) return;
|
|
133
|
+
const value = slot.value;
|
|
134
|
+
|
|
135
|
+
// Native paste / IME may deliver multi-character input — defer to
|
|
136
|
+
// the paste handler for that case; here, normalize single-char.
|
|
137
|
+
if (value.length === 0) {
|
|
138
|
+
notifyChange();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (value.length > 1) {
|
|
143
|
+
// Multi-char input (paste fallback path on browsers that fire
|
|
144
|
+
// `input` instead of `paste`): keep only the last character if
|
|
145
|
+
// it matches the pattern; the explicit `paste` handler covers
|
|
146
|
+
// the typical Cmd+V flow.
|
|
147
|
+
const last = value[value.length - 1] ?? '';
|
|
148
|
+
slot.value = pattern.test(last) ? last : '';
|
|
149
|
+
} else if (!pattern.test(value)) {
|
|
150
|
+
// Reject invalid character.
|
|
151
|
+
slot.value = '';
|
|
152
|
+
notifyChange();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
notifyChange();
|
|
157
|
+
|
|
158
|
+
// Advance focus to the next empty slot (or the next slot if all
|
|
159
|
+
// remaining are filled — matches the Reka UI / shadcn UX).
|
|
160
|
+
if (slot.value.length === 1 && idx < slots.length - 1) {
|
|
161
|
+
focusSlot(idx + 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
166
|
+
const idx = findThisIndex(event.target);
|
|
167
|
+
if (idx === -1) return;
|
|
168
|
+
const slot = slots[idx];
|
|
169
|
+
if (!slot) return;
|
|
170
|
+
|
|
171
|
+
if (event.key === 'Backspace') {
|
|
172
|
+
if (slot.value.length === 0 && idx > 0) {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
const prev = slots[idx - 1];
|
|
175
|
+
if (prev) {
|
|
176
|
+
prev.value = '';
|
|
177
|
+
notifyChange();
|
|
178
|
+
focusSlot(idx - 1);
|
|
179
|
+
}
|
|
180
|
+
} else if (slot.value.length > 0) {
|
|
181
|
+
// Clear current slot but stay on it — the next Backspace then
|
|
182
|
+
// moves to the previous (canonical OTP UX).
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
slot.value = '';
|
|
185
|
+
notifyChange();
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (event.key === 'ArrowLeft' && idx > 0) {
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
focusSlot(idx - 1);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (event.key === 'ArrowRight' && idx < slots.length - 1) {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
focusSlot(idx + 1);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function handlePaste(event: ClipboardEvent): void {
|
|
204
|
+
const idx = findThisIndex(event.target);
|
|
205
|
+
if (idx === -1) return;
|
|
206
|
+
const data = event.clipboardData?.getData('text') ?? '';
|
|
207
|
+
if (!data) return;
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
|
|
210
|
+
// Filter to pattern-matching characters; distribute starting at
|
|
211
|
+
// the focused slot.
|
|
212
|
+
const valid = Array.from(data).filter((ch) => pattern.test(ch));
|
|
213
|
+
if (valid.length === 0) return;
|
|
214
|
+
|
|
215
|
+
let cursor = idx;
|
|
216
|
+
for (const ch of valid) {
|
|
217
|
+
if (cursor >= slots.length) break;
|
|
218
|
+
const slot = slots[cursor];
|
|
219
|
+
if (slot) slot.value = ch;
|
|
220
|
+
cursor += 1;
|
|
221
|
+
}
|
|
222
|
+
notifyChange();
|
|
223
|
+
|
|
224
|
+
// Land focus on the slot after the last filled one (or the last
|
|
225
|
+
// slot if the paste filled to the end).
|
|
226
|
+
const landingIdx = Math.min(cursor, slots.length - 1);
|
|
227
|
+
focusSlot(landingIdx);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function handleFocus(event: FocusEvent): void {
|
|
231
|
+
if (!(event.target instanceof HTMLInputElement)) return;
|
|
232
|
+
// Select existing content so a typed digit overwrites it cleanly,
|
|
233
|
+
// matching Reka UI's "click any slot to retype" UX.
|
|
234
|
+
event.target.select();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
slots.forEach((slot) => {
|
|
238
|
+
slot.addEventListener('input', handleInput);
|
|
239
|
+
slot.addEventListener('keydown', handleKeydown);
|
|
240
|
+
slot.addEventListener('paste', handlePaste);
|
|
241
|
+
slot.addEventListener('focus', handleFocus);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return function teardown(): void {
|
|
245
|
+
slots.forEach((slot) => {
|
|
246
|
+
slot.removeEventListener('input', handleInput);
|
|
247
|
+
slot.removeEventListener('keydown', handleKeydown);
|
|
248
|
+
slot.removeEventListener('paste', handlePaste);
|
|
249
|
+
slot.removeEventListener('focus', handleFocus);
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
}
|