@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.
Files changed (227) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONVENTIONS.md +1499 -0
  3. package/LICENSE +21 -0
  4. package/README.md +71 -0
  5. package/components/action/button/Button.astro +95 -0
  6. package/components/action/button/Button.vue +92 -0
  7. package/components/action/button/IconButton.astro +86 -0
  8. package/components/action/button/IconButton.vue +85 -0
  9. package/components/action/button/button.css +194 -0
  10. package/components/action/toggle/Toggle.vue +38 -0
  11. package/components/action/toggle/toggle.css +77 -0
  12. package/components/action/toggle-group/ToggleGroup.vue +67 -0
  13. package/components/action/toggle-group/toggle-group.css +78 -0
  14. package/components/display/avatar/Avatar.astro +17 -0
  15. package/components/display/avatar/Avatar.vue +30 -0
  16. package/components/display/avatar/AvatarStack.astro +9 -0
  17. package/components/display/avatar/AvatarStack.vue +11 -0
  18. package/components/display/avatar/avatar.css +58 -0
  19. package/components/display/badge/Badge.astro +15 -0
  20. package/components/display/badge/Badge.vue +23 -0
  21. package/components/display/badge/badge.css +56 -0
  22. package/components/display/empty/Empty.astro +9 -0
  23. package/components/display/empty/Empty.vue +11 -0
  24. package/components/display/empty/empty.css +39 -0
  25. package/components/display/icon/Icon.astro +52 -0
  26. package/components/display/icon/Icon.vue +57 -0
  27. package/components/display/icon/icon.css +47 -0
  28. package/components/feedback/alert/Alert.astro +52 -0
  29. package/components/feedback/alert/Alert.vue +60 -0
  30. package/components/feedback/alert/alert.css +78 -0
  31. package/components/feedback/progress/Progress.astro +68 -0
  32. package/components/feedback/progress/Progress.vue +82 -0
  33. package/components/feedback/progress/progress.css +68 -0
  34. package/components/feedback/skeleton/Skeleton.astro +32 -0
  35. package/components/feedback/skeleton/Skeleton.vue +39 -0
  36. package/components/feedback/skeleton/skeleton.css +56 -0
  37. package/components/feedback/spinner/Spinner.astro +25 -0
  38. package/components/feedback/spinner/Spinner.vue +36 -0
  39. package/components/feedback/spinner/spinner.css +91 -0
  40. package/components/feedback/toast/Toast.astro +50 -0
  41. package/components/feedback/toast/Toast.vue +74 -0
  42. package/components/feedback/toast/toast.css +128 -0
  43. package/components/form/checkbox/Checkbox.astro +79 -0
  44. package/components/form/checkbox/Checkbox.vue +95 -0
  45. package/components/form/checkbox/checkbox.css +59 -0
  46. package/components/form/combobox/Combobox.vue +508 -0
  47. package/components/form/combobox/combobox.css +110 -0
  48. package/components/form/date-input/DateInput.astro +105 -0
  49. package/components/form/date-input/DateInput.vue +121 -0
  50. package/components/form/date-input/date-input.css +19 -0
  51. package/components/form/form/Form.astro +106 -0
  52. package/components/form/form/Form.vue +181 -0
  53. package/components/form/form/form.css +46 -0
  54. package/components/form/input-otp/InputOTP.astro +147 -0
  55. package/components/form/input-otp/InputOTP.vue +209 -0
  56. package/components/form/input-otp/input-otp.css +52 -0
  57. package/components/form/label/Label.astro +13 -0
  58. package/components/form/label/Label.vue +20 -0
  59. package/components/form/label/label.css +11 -0
  60. package/components/form/number-field/NumberField.astro +142 -0
  61. package/components/form/number-field/NumberField.vue +155 -0
  62. package/components/form/number-field/number-field.css +115 -0
  63. package/components/form/radio-group/RadioGroup.astro +105 -0
  64. package/components/form/radio-group/RadioGroup.vue +110 -0
  65. package/components/form/radio-group/radio-group.css +114 -0
  66. package/components/form/radio-group/types.ts +14 -0
  67. package/components/form/select/Segmented.vue +36 -0
  68. package/components/form/select/Select.astro +105 -0
  69. package/components/form/select/Select.vue +109 -0
  70. package/components/form/select/select.css +96 -0
  71. package/components/form/slider/Slider.astro +205 -0
  72. package/components/form/slider/Slider.vue +321 -0
  73. package/components/form/slider/slider.css +115 -0
  74. package/components/form/switch/Switch.astro +75 -0
  75. package/components/form/switch/Switch.vue +89 -0
  76. package/components/form/switch/switch.css +64 -0
  77. package/components/form/tags-input/TagsInput.astro +153 -0
  78. package/components/form/tags-input/TagsInput.vue +207 -0
  79. package/components/form/tags-input/tags-input.css +128 -0
  80. package/components/form/text-input/TextInput.astro +84 -0
  81. package/components/form/text-input/TextInput.vue +99 -0
  82. package/components/form/text-input/text-input.css +165 -0
  83. package/components/form/textarea/Textarea.astro +86 -0
  84. package/components/form/textarea/Textarea.vue +102 -0
  85. package/components/form/textarea/textarea.css +25 -0
  86. package/components/layout/accordion/Accordion.vue +59 -0
  87. package/components/layout/accordion/accordion.css +87 -0
  88. package/components/layout/card/Card.astro +13 -0
  89. package/components/layout/card/Card.vue +20 -0
  90. package/components/layout/card/card.css +55 -0
  91. package/components/layout/collapsible/Collapsible.vue +77 -0
  92. package/components/layout/collapsible/collapsible.css +76 -0
  93. package/components/layout/separator/Separator.astro +31 -0
  94. package/components/layout/separator/Separator.vue +33 -0
  95. package/components/layout/separator/separator.css +27 -0
  96. package/components/layout/table/DataTable.vue +127 -0
  97. package/components/layout/table/Table.astro +116 -0
  98. package/components/layout/table/Table.vue +146 -0
  99. package/components/layout/table/TableRow.vue +59 -0
  100. package/components/layout/table/table.css +201 -0
  101. package/components/layout/table/types.ts +35 -0
  102. package/components/layout/table/useTable.ts +7 -0
  103. package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
  104. package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
  105. package/components/navigation/breadcrumb/breadcrumb.css +37 -0
  106. package/components/navigation/navbar/Navbar.astro +62 -0
  107. package/components/navigation/navbar/Navbar.vue +50 -0
  108. package/components/navigation/navbar/navbar.css +77 -0
  109. package/components/navigation/pagination/Pagination.vue +107 -0
  110. package/components/navigation/pagination/pagination.css +53 -0
  111. package/components/navigation/sidebar/Sidebar.astro +132 -0
  112. package/components/navigation/sidebar/Sidebar.vue +174 -0
  113. package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
  114. package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
  115. package/components/navigation/sidebar/sidebar.css +303 -0
  116. package/components/navigation/sidebar/types.ts +72 -0
  117. package/components/navigation/tabs/Tabs.vue +84 -0
  118. package/components/navigation/tabs/tabs.css +39 -0
  119. package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
  120. package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
  121. package/components/overlay/alert-dialog/alert-dialog.css +57 -0
  122. package/components/overlay/command/Command.vue +356 -0
  123. package/components/overlay/command/command.css +179 -0
  124. package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
  125. package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
  126. package/components/overlay/modal/Modal.astro +66 -0
  127. package/components/overlay/modal/Modal.vue +85 -0
  128. package/components/overlay/modal/modal.css +60 -0
  129. package/components/overlay/popover/Popover.vue +113 -0
  130. package/components/overlay/popover/popover.css +53 -0
  131. package/components/overlay/sheet/Sheet.vue +88 -0
  132. package/components/overlay/sheet/sheet.css +108 -0
  133. package/components/overlay/tooltip/Tooltip.vue +210 -0
  134. package/components/overlay/tooltip/tooltip.css +50 -0
  135. package/composables/useUrlSort.ts +48 -0
  136. package/icons/alert-triangle.ts +1 -0
  137. package/icons/arrow-down.ts +1 -0
  138. package/icons/arrow-up-down.ts +5 -0
  139. package/icons/arrow-up.ts +1 -0
  140. package/icons/bell.ts +1 -0
  141. package/icons/check.ts +1 -0
  142. package/icons/chevron-down.ts +1 -0
  143. package/icons/chevron-left.ts +1 -0
  144. package/icons/chevron-right.ts +1 -0
  145. package/icons/chevron-up-down.ts +5 -0
  146. package/icons/chevron-up.ts +1 -0
  147. package/icons/circle-alert.ts +1 -0
  148. package/icons/circle-check.ts +1 -0
  149. package/icons/clipboard.ts +1 -0
  150. package/icons/download.ts +1 -0
  151. package/icons/edit.ts +1 -0
  152. package/icons/external-link.ts +1 -0
  153. package/icons/eye.ts +1 -0
  154. package/icons/file.ts +1 -0
  155. package/icons/filter.ts +1 -0
  156. package/icons/folder.ts +1 -0
  157. package/icons/image.ts +1 -0
  158. package/icons/inbox.ts +1 -0
  159. package/icons/index.ts +91 -0
  160. package/icons/info.ts +1 -0
  161. package/icons/layers.ts +1 -0
  162. package/icons/link-2.ts +1 -0
  163. package/icons/list.ts +1 -0
  164. package/icons/loader.ts +3 -0
  165. package/icons/menu.ts +1 -0
  166. package/icons/more-horizontal.ts +1 -0
  167. package/icons/more-vertical.ts +1 -0
  168. package/icons/plus-circle.ts +1 -0
  169. package/icons/plus.ts +1 -0
  170. package/icons/save.ts +1 -0
  171. package/icons/search.ts +1 -0
  172. package/icons/send.ts +1 -0
  173. package/icons/settings.ts +1 -0
  174. package/icons/tool.ts +1 -0
  175. package/icons/trash-2.ts +1 -0
  176. package/icons/trash.ts +1 -0
  177. package/icons/upload-cloud.ts +1 -0
  178. package/icons/upload.ts +1 -0
  179. package/icons/x.ts +1 -0
  180. package/package.json +150 -0
  181. package/styles/0-settings/colors.css +241 -0
  182. package/styles/0-settings/index.css +5 -0
  183. package/styles/0-settings/layout.css +52 -0
  184. package/styles/0-settings/motion.css +11 -0
  185. package/styles/0-settings/spacing.css +15 -0
  186. package/styles/0-settings/typography.css +37 -0
  187. package/styles/0-utils/index.css +1 -0
  188. package/styles/1-reset/index.css +1 -0
  189. package/styles/1-reset/reset.css +26 -0
  190. package/styles/2-base/base.css +42 -0
  191. package/styles/2-base/forms.css +23 -0
  192. package/styles/2-base/index.css +2 -0
  193. package/styles/3-layout/container.css +57 -0
  194. package/styles/3-layout/index.css +2 -0
  195. package/styles/3-layout/section.css +17 -0
  196. package/styles/5-utilities/accessibility.css +13 -0
  197. package/styles/5-utilities/index.css +2 -0
  198. package/styles/5-utilities/text.css +5 -0
  199. package/styles/main.css +8 -0
  200. package/styles/styles.d.ts +6 -0
  201. package/utils/a11y/focus.ts +68 -0
  202. package/utils/a11y/id.ts +10 -0
  203. package/utils/a11y/index.ts +9 -0
  204. package/utils/a11y/keyboard.ts +32 -0
  205. package/utils/a11y/live-region.ts +36 -0
  206. package/utils/controllers/dialog.ts +205 -0
  207. package/utils/controllers/disclosure.ts +117 -0
  208. package/utils/controllers/form.ts +524 -0
  209. package/utils/controllers/index.ts +39 -0
  210. package/utils/controllers/menu.ts +255 -0
  211. package/utils/controllers/number-field.ts +103 -0
  212. package/utils/controllers/otp.ts +252 -0
  213. package/utils/controllers/popover.ts +434 -0
  214. package/utils/controllers/sidebar.ts +610 -0
  215. package/utils/controllers/slider.ts +336 -0
  216. package/utils/controllers/tags-input.ts +255 -0
  217. package/utils/controllers/toast.ts +426 -0
  218. package/utils/dom/index.ts +1 -0
  219. package/utils/dom/scroll-lock.ts +48 -0
  220. package/utils/index.ts +3 -0
  221. package/utils/sort/index.ts +3 -0
  222. package/utils/sort/serialize.ts +19 -0
  223. package/utils/sort/state.ts +11 -0
  224. package/utils/sort/types.ts +15 -0
  225. package/utils/validation/form.ts +93 -0
  226. package/utils/validation/index.ts +13 -0
  227. 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
+ }