@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,336 @@
1
+ /**
2
+ * Configuration for `mountSlider`. The controller wires the
3
+ * `<button role="slider">` thumb(s) inside a Slider track to the
4
+ * canonical WAI-ARIA Slider keyboard + pointer pattern:
5
+ * - ArrowRight / ArrowUp: increment by `step`.
6
+ * - ArrowLeft / ArrowDown: decrement by `step`.
7
+ * - PageUp / PageDown: increment / decrement by 10× `step`.
8
+ * - Home / End: jump to `min` / `max`.
9
+ * - Mouse / touch drag: pointer-position-derived value, clamped to
10
+ * `[min, max]` and snapped to `step`.
11
+ * - Track click: jumps the closest thumb to the click position.
12
+ *
13
+ * `onChange` is fired after every successful change with the current
14
+ * value (single-thumb) or values tuple (range mode). Range mode is
15
+ * detected from `initialValue` shape: `number` → single, `[number,
16
+ * number]` → range. Range mode preserves ordering — if the lower
17
+ * thumb is dragged past the upper, both end at the same value (no
18
+ * crossing); the same applies in reverse for the upper thumb.
19
+ */
20
+ export interface SliderConfig {
21
+ /** Minimum value (default 0). */
22
+ min?: number;
23
+ /** Maximum value (default 100). */
24
+ max?: number;
25
+ /** Step granularity (default 1). */
26
+ step?: number;
27
+ /**
28
+ * Initial value. Provide a number for single-thumb mode, a
29
+ * `[lower, upper]` tuple for range mode. The tuple shape locks the
30
+ * controller into range mode for its lifetime.
31
+ */
32
+ initialValue?: number | [number, number];
33
+ /** Disabled controls reject all keyboard / pointer interactions. */
34
+ disabled?: boolean;
35
+ /** Callback fired after every successful value change. */
36
+ onChange?: (value: number | [number, number]) => void;
37
+ }
38
+
39
+ /**
40
+ * Mount the Slider controller on `trackEl`, which MUST be the
41
+ * `<div class="v-slider">` track element containing one or two
42
+ * `<button class="v-slider__thumb" role="slider">` children. The
43
+ * track owns the clickable / draggable surface; the thumbs own
44
+ * keyboard focus and the per-thumb ARIA attributes.
45
+ *
46
+ * Lifecycle (non-stateful per CONVENTIONS — teardown-only signature):
47
+ * 1. On mount: locate thumbs, read `min` / `max` / `step` from
48
+ * `data-min` / `data-max` / `data-step` (set via dataset by the
49
+ * framework component), seed values from `initialValue` or from
50
+ * the thumbs' `aria-valuenow` attributes (Astro SSR path).
51
+ * 2. Wire `keydown` on each thumb (WAI-ARIA Slider pattern), and
52
+ * `pointerdown` on the track + thumbs for drag.
53
+ * 3. Every value change updates the thumb's
54
+ * `aria-valuenow` / `aria-valuetext` + the track's `data-value`
55
+ * + a CSS variable `--v-slider-fill-start` /
56
+ * `--v-slider-fill-end` (range mode) on the track for the filled
57
+ * progress bar visual. Dispatches `input` and `change` events on
58
+ * the track and (if present) on the hidden form input(s).
59
+ * 4. `teardown()`: removes all listeners. Track / thumb DOM is left
60
+ * in place; the framework owns markup cleanup.
61
+ *
62
+ * Mounting on a track without thumb children is a graceful no-op.
63
+ */
64
+ export function mountSlider(
65
+ trackEl: HTMLElement,
66
+ config: SliderConfig = {},
67
+ ): () => void {
68
+ const thumbs = Array.from(
69
+ trackEl.querySelectorAll<HTMLButtonElement>('button.v-slider__thumb'),
70
+ );
71
+ if (thumbs.length === 0) {
72
+ return () => {};
73
+ }
74
+
75
+ const min = config.min ?? readNumber(trackEl.dataset.min, 0);
76
+ const max = config.max ?? readNumber(trackEl.dataset.max, 100);
77
+ const step = config.step ?? readNumber(trackEl.dataset.step, 1);
78
+ const disabled = config.disabled ?? trackEl.dataset.disabled === 'true';
79
+
80
+ // Determine mode: range if there are 2 thumbs OR if config.initialValue is a tuple.
81
+ const isRange =
82
+ thumbs.length === 2 ||
83
+ (Array.isArray(config.initialValue) &&
84
+ config.initialValue.length === 2);
85
+
86
+ // Seed values. Priority: config.initialValue, then thumbs' aria-valuenow.
87
+ const values: number[] = seedValues();
88
+
89
+ function seedValues(): number[] {
90
+ if (config.initialValue !== undefined) {
91
+ return Array.isArray(config.initialValue)
92
+ ? [config.initialValue[0], config.initialValue[1]]
93
+ : [config.initialValue];
94
+ }
95
+ return thumbs.map((thumb) => {
96
+ const raw = thumb.getAttribute('aria-valuenow');
97
+ const parsed = raw === null ? min : Number.parseFloat(raw);
98
+ return Number.isFinite(parsed) ? parsed : min;
99
+ });
100
+ }
101
+
102
+ function readNumber(raw: string | undefined, fallback: number): number {
103
+ if (raw === undefined) return fallback;
104
+ const parsed = Number.parseFloat(raw);
105
+ return Number.isFinite(parsed) ? parsed : fallback;
106
+ }
107
+
108
+ function clamp(value: number, lo: number, hi: number): number {
109
+ if (value < lo) return lo;
110
+ if (value > hi) return hi;
111
+ return value;
112
+ }
113
+
114
+ function snapToStep(value: number): number {
115
+ if (step <= 0) return value;
116
+ const stepped = Math.round((value - min) / step) * step + min;
117
+ // Round-trip floats so 0.1 + 0.2 doesn't surface as 0.30000000000004.
118
+ const decimals = (step.toString().split('.')[1] ?? '').length;
119
+ return decimals > 0
120
+ ? Number.parseFloat(stepped.toFixed(decimals))
121
+ : stepped;
122
+ }
123
+
124
+ function setValue(thumbIndex: number, raw: number): void {
125
+ const snapped = snapToStep(clamp(raw, min, max));
126
+ if (isRange && thumbs.length === 2) {
127
+ // Enforce no-crossing ordering. Lower thumb (index 0) can't go
128
+ // above the upper; upper thumb (index 1) can't go below the lower.
129
+ if (thumbIndex === 0) {
130
+ const otherValue = values[1] ?? max;
131
+ values[0] = Math.min(snapped, otherValue);
132
+ } else {
133
+ const otherValue = values[0] ?? min;
134
+ values[1] = Math.max(snapped, otherValue);
135
+ }
136
+ } else {
137
+ values[thumbIndex] = snapped;
138
+ }
139
+ render();
140
+ notifyChange();
141
+ }
142
+
143
+ function render(): void {
144
+ thumbs.forEach((thumb, idx) => {
145
+ const value = values[idx] ?? min;
146
+ thumb.setAttribute('aria-valuenow', String(value));
147
+ thumb.setAttribute('aria-valuemin', String(min));
148
+ thumb.setAttribute('aria-valuemax', String(max));
149
+ if (isRange) {
150
+ const role = idx === 0 ? 'Lower bound' : 'Upper bound';
151
+ thumb.setAttribute('aria-valuetext', `${role}: ${value}`);
152
+ } else {
153
+ thumb.setAttribute('aria-valuetext', String(value));
154
+ }
155
+ const pct = ((value - min) / (max - min)) * 100;
156
+ thumb.style.insetInlineStart = `${pct}%`;
157
+ });
158
+
159
+ // Track-level CSS vars for the filled progress segment.
160
+ if (isRange && values.length === 2) {
161
+ const [v0, v1] = values as [number, number];
162
+ const startPct = ((v0 - min) / (max - min)) * 100;
163
+ const endPct = ((v1 - min) / (max - min)) * 100;
164
+ trackEl.style.setProperty('--v-slider-fill-start', `${startPct}%`);
165
+ trackEl.style.setProperty('--v-slider-fill-end', `${endPct}%`);
166
+ } else {
167
+ const v = values[0] ?? min;
168
+ const pct = ((v - min) / (max - min)) * 100;
169
+ trackEl.style.setProperty('--v-slider-fill-start', '0%');
170
+ trackEl.style.setProperty('--v-slider-fill-end', `${pct}%`);
171
+ }
172
+ }
173
+
174
+ function notifyChange(): void {
175
+ const payload: number | [number, number] =
176
+ isRange && values.length === 2
177
+ ? [values[0] as number, values[1] as number]
178
+ : (values[0] as number);
179
+ config.onChange?.(payload);
180
+
181
+ // Sync any hidden form inputs (the Astro / Vue component renders
182
+ // `<input type="hidden" name="<name>">` (single) or two such with
183
+ // `[data-bound="lower"|"upper"]` for range — controller writes
184
+ // their `value` attribute and dispatches input/change so
185
+ // `mountForm` sees the change).
186
+ const hiddenSingle =
187
+ trackEl.parentElement?.querySelector<HTMLInputElement>(
188
+ 'input.v-slider__hidden:not([data-bound])',
189
+ );
190
+ const hiddenLower =
191
+ trackEl.parentElement?.querySelector<HTMLInputElement>(
192
+ 'input.v-slider__hidden[data-bound="lower"]',
193
+ );
194
+ const hiddenUpper =
195
+ trackEl.parentElement?.querySelector<HTMLInputElement>(
196
+ 'input.v-slider__hidden[data-bound="upper"]',
197
+ );
198
+ if (isRange && hiddenLower && hiddenUpper && values.length === 2) {
199
+ hiddenLower.value = String(values[0]);
200
+ hiddenUpper.value = String(values[1]);
201
+ hiddenLower.dispatchEvent(new Event('input', { bubbles: true }));
202
+ hiddenLower.dispatchEvent(new Event('change', { bubbles: true }));
203
+ hiddenUpper.dispatchEvent(new Event('input', { bubbles: true }));
204
+ hiddenUpper.dispatchEvent(new Event('change', { bubbles: true }));
205
+ } else if (!isRange && hiddenSingle && values[0] !== undefined) {
206
+ hiddenSingle.value = String(values[0]);
207
+ hiddenSingle.dispatchEvent(new Event('input', { bubbles: true }));
208
+ hiddenSingle.dispatchEvent(new Event('change', { bubbles: true }));
209
+ }
210
+
211
+ // Bubble track-level events for general consumer listeners.
212
+ trackEl.dispatchEvent(new Event('input', { bubbles: true }));
213
+ trackEl.dispatchEvent(new Event('change', { bubbles: true }));
214
+ }
215
+
216
+ function thumbIndexOf(target: EventTarget | null): number {
217
+ if (!(target instanceof HTMLButtonElement)) return -1;
218
+ return thumbs.indexOf(target);
219
+ }
220
+
221
+ function handleKeydown(event: KeyboardEvent): void {
222
+ if (disabled) return;
223
+ const idx = thumbIndexOf(event.target);
224
+ if (idx === -1) return;
225
+ const current = values[idx] ?? min;
226
+
227
+ let next: number;
228
+ switch (event.key) {
229
+ case 'ArrowRight':
230
+ case 'ArrowUp':
231
+ next = current + step;
232
+ break;
233
+ case 'ArrowLeft':
234
+ case 'ArrowDown':
235
+ next = current - step;
236
+ break;
237
+ case 'PageUp':
238
+ next = current + step * 10;
239
+ break;
240
+ case 'PageDown':
241
+ next = current - step * 10;
242
+ break;
243
+ case 'Home':
244
+ next = min;
245
+ break;
246
+ case 'End':
247
+ next = max;
248
+ break;
249
+ default:
250
+ return;
251
+ }
252
+ event.preventDefault();
253
+ setValue(idx, next);
254
+ }
255
+
256
+ function valueFromPointer(event: PointerEvent): number {
257
+ const rect = trackEl.getBoundingClientRect();
258
+ const x = event.clientX - rect.left;
259
+ const ratio = clamp(x / rect.width, 0, 1);
260
+ return min + ratio * (max - min);
261
+ }
262
+
263
+ function nearestThumbIndex(target: number): number {
264
+ if (!isRange || thumbs.length < 2) return 0;
265
+ const v0 = values[0] ?? min;
266
+ const v1 = values[1] ?? max;
267
+ return Math.abs(target - v0) <= Math.abs(target - v1) ? 0 : 1;
268
+ }
269
+
270
+ let activeDragIndex = -1;
271
+ let dragPointerId: number | null = null;
272
+
273
+ function handlePointerDown(event: PointerEvent): void {
274
+ if (disabled) return;
275
+ if (event.button !== undefined && event.button !== 0) return;
276
+
277
+ const target = event.target;
278
+ let idx: number;
279
+ if (target instanceof HTMLButtonElement && thumbs.includes(target)) {
280
+ idx = thumbs.indexOf(target);
281
+ } else {
282
+ // Click on track (not on a thumb) — jump nearest thumb.
283
+ const value = valueFromPointer(event);
284
+ idx = nearestThumbIndex(value);
285
+ setValue(idx, value);
286
+ }
287
+ if (idx === -1) return;
288
+
289
+ activeDragIndex = idx;
290
+ dragPointerId = event.pointerId;
291
+ // Capture so subsequent pointermove / pointerup events fire even if
292
+ // the cursor leaves the track box.
293
+ trackEl.setPointerCapture(event.pointerId);
294
+ // Move focus to the thumb being dragged so subsequent keyboard
295
+ // input acts on it.
296
+ thumbs[idx]?.focus();
297
+ event.preventDefault();
298
+ }
299
+
300
+ function handlePointerMove(event: PointerEvent): void {
301
+ if (activeDragIndex === -1) return;
302
+ if (dragPointerId !== null && event.pointerId !== dragPointerId) return;
303
+ const value = valueFromPointer(event);
304
+ setValue(activeDragIndex, value);
305
+ }
306
+
307
+ function handlePointerUp(event: PointerEvent): void {
308
+ if (dragPointerId !== null && event.pointerId !== dragPointerId) return;
309
+ if (trackEl.hasPointerCapture(event.pointerId)) {
310
+ trackEl.releasePointerCapture(event.pointerId);
311
+ }
312
+ activeDragIndex = -1;
313
+ dragPointerId = null;
314
+ }
315
+
316
+ thumbs.forEach((thumb) => {
317
+ thumb.addEventListener('keydown', handleKeydown);
318
+ });
319
+ trackEl.addEventListener('pointerdown', handlePointerDown);
320
+ trackEl.addEventListener('pointermove', handlePointerMove);
321
+ trackEl.addEventListener('pointerup', handlePointerUp);
322
+ trackEl.addEventListener('pointercancel', handlePointerUp);
323
+
324
+ // Initial render to seed positions / ARIA from the seeded values.
325
+ render();
326
+
327
+ return function teardown(): void {
328
+ thumbs.forEach((thumb) => {
329
+ thumb.removeEventListener('keydown', handleKeydown);
330
+ });
331
+ trackEl.removeEventListener('pointerdown', handlePointerDown);
332
+ trackEl.removeEventListener('pointermove', handlePointerMove);
333
+ trackEl.removeEventListener('pointerup', handlePointerUp);
334
+ trackEl.removeEventListener('pointercancel', handlePointerUp);
335
+ };
336
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Configuration for `mountTagsInput`. The controller wires the inner
3
+ * entry input + per-tag remove buttons inside a TagsInput wrapper to
4
+ * the canonical add / remove behaviour:
5
+ * - Typing + a configured separator (default `,` or Enter) adds a tag.
6
+ * - Backspace on the empty entry input removes the last tag.
7
+ * - Click on a tag's remove button removes that tag.
8
+ *
9
+ * `onChange` is invoked after every successful add / remove with the
10
+ * resulting tags array (a fresh `string[]` — the controller does not
11
+ * leak its internal mutable buffer). Consumers that need to mirror the
12
+ * tags list into reactive state read from this hook; the controller
13
+ * is non-stateful and does not surface a control object per the
14
+ * teardown-only signature locked in CONVENTIONS.
15
+ */
16
+ export interface TagsInputConfig {
17
+ /** Initial tags array (defaults to empty). Re-rendered on mount. */
18
+ initialTags?: string[];
19
+ /**
20
+ * Callback fired after every add / remove. Receives a fresh array
21
+ * (controllers MUST NOT leak internal mutable state).
22
+ */
23
+ onChange?: (tags: string[]) => void;
24
+ /**
25
+ * Maximum number of tags. Beyond this, add operations are no-ops
26
+ * (the entry input keeps its typed value). `undefined` disables the
27
+ * cap (default).
28
+ */
29
+ maxTags?: number;
30
+ /**
31
+ * Characters or key names that finalize a tag. Single-character
32
+ * strings are matched against the typed character; multi-character
33
+ * strings are matched against `event.key` (e.g. `'Enter'`). Default
34
+ * `[',', 'Enter']`.
35
+ */
36
+ separators?: string[];
37
+ }
38
+
39
+ /**
40
+ * Mount the TagsInput controller on `wrapperEl`, which MUST be the
41
+ * `<div class="v-tags-input">` wrapper element containing both the
42
+ * `<input class="v-tags-input__entry">` and (optionally) any
43
+ * pre-rendered `<span class="v-tags-input__tag">` children.
44
+ *
45
+ * Lifecycle (non-stateful per CONVENTIONS — teardown-only signature,
46
+ * matching `mountForm` / `mountNumberField`):
47
+ * 1. On mount: locate the inner entry input. If `initialTags` is
48
+ * provided, the controller renders the tag pills DOM-side (this
49
+ * keeps Astro's SSR markup free of duplicated state — the wrapper
50
+ * only needs to render the entry input, the controller fills in
51
+ * the rest from `data-initial-tags`).
52
+ * 2. Wire `keydown` on the entry input for separator handling and
53
+ * Backspace-on-empty-removes-last; wire `click` delegation on the
54
+ * wrapper for per-tag remove buttons (so dynamically-added tags
55
+ * don't need re-listening).
56
+ * 3. Every add / remove dispatches `input` and `change` events
57
+ * (bubbling) on the entry input so Form's `mountForm` controller
58
+ * and consumer-attached listeners observe the change. `onChange`
59
+ * fires with the fresh tags array.
60
+ * 4. `teardown()`: removes the keydown + click listeners; the
61
+ * rendered DOM is left in place (the framework owns markup
62
+ * cleanup on unmount).
63
+ *
64
+ * Mounting on a wrapper without an inner entry input is a graceful
65
+ * no-op — the controller returns a teardown function that does
66
+ * nothing, mirroring `mountNumberField`'s defensive pattern.
67
+ *
68
+ * Per CONVENTIONS § "Use native APIs where applicable", we deliberately
69
+ * do NOT add a `role="combobox"` ARIA layer in v1 — the wrapper is a
70
+ * native input + list-of-tags; the WAI-ARIA "tags input" pattern is
71
+ * still actively evolving (APG calls it "List input"); the v1 default
72
+ * uses native input semantics and the per-tag buttons carry their own
73
+ * `aria-label="Remove tag <label>"` so the surface is fully reachable
74
+ * with simpler markup.
75
+ */
76
+ export function mountTagsInput(
77
+ wrapperEl: HTMLElement,
78
+ config: TagsInputConfig = {},
79
+ ): () => void {
80
+ const entry = wrapperEl.querySelector<HTMLInputElement>(
81
+ 'input.v-tags-input__entry',
82
+ );
83
+ if (!entry) {
84
+ // Graceful no-op: wrapper is missing the inner entry input.
85
+ return () => {};
86
+ }
87
+
88
+ const onChange = config.onChange;
89
+ const maxTags = config.maxTags;
90
+ const separators = config.separators ?? [',', 'Enter'];
91
+
92
+ // Internal tag buffer — the source of truth while the controller is
93
+ // mounted. Initialised from `config.initialTags` (Vue path) or from
94
+ // `data-initial-tags` JSON on the wrapper (Astro SSR path).
95
+ let tags: string[] = readInitialTags(wrapperEl, config.initialTags);
96
+
97
+ // Render initial pills if any tags were carried in. Astro renders
98
+ // its initial SSR pass with the full pill DOM already in place; the
99
+ // Vue path renders pills reactively. To keep the controller usable
100
+ // from both, we only render when the wrapper has no existing tag
101
+ // children (avoids duplication).
102
+ const hasExistingPills =
103
+ wrapperEl.querySelector('.v-tags-input__tag') !== null;
104
+ if (!hasExistingPills && tags.length > 0) {
105
+ renderTags();
106
+ } else if (hasExistingPills) {
107
+ // Sync internal buffer from existing DOM (Astro SSR path).
108
+ tags = Array.from(
109
+ wrapperEl.querySelectorAll<HTMLElement>(
110
+ '.v-tags-input__tag[data-tag-value]',
111
+ ),
112
+ ).map((el) => el.dataset.tagValue ?? '');
113
+ }
114
+
115
+ function readInitialTags(
116
+ el: HTMLElement,
117
+ fromConfig: string[] | undefined,
118
+ ): string[] {
119
+ if (fromConfig) return [...fromConfig];
120
+ const raw = el.dataset.initialTags;
121
+ if (!raw) return [];
122
+ try {
123
+ const parsed: unknown = JSON.parse(raw);
124
+ if (Array.isArray(parsed)) {
125
+ return parsed.filter((v): v is string => typeof v === 'string');
126
+ }
127
+ } catch {
128
+ // Malformed — graceful empty start.
129
+ }
130
+ return [];
131
+ }
132
+
133
+ function notifyChange(): void {
134
+ // Fresh copy so consumers can't mutate the internal buffer.
135
+ onChange?.([...tags]);
136
+ // Keep Form's mountForm / consumer listeners in the loop. The
137
+ // entry input is the FormData carrier — its current value is the
138
+ // empty entry, but its dataset reflects the tag list so server
139
+ // payload shaping is the consumer's call (see components/form/tags-input/AUDIT.md).
140
+ entry?.dispatchEvent(new Event('input', { bubbles: true }));
141
+ entry?.dispatchEvent(new Event('change', { bubbles: true }));
142
+ }
143
+
144
+ function addTag(raw: string): boolean {
145
+ const value = raw.trim();
146
+ if (!value) return false;
147
+ if (maxTags !== undefined && tags.length >= maxTags) return false;
148
+ // Reject duplicates — the canonical TagsInput UX is set semantics.
149
+ if (tags.includes(value)) return false;
150
+ tags = [...tags, value];
151
+ renderTags();
152
+ notifyChange();
153
+ return true;
154
+ }
155
+
156
+ function removeTag(index: number): void {
157
+ if (index < 0 || index >= tags.length) return;
158
+ tags = tags.filter((_, i) => i !== index);
159
+ renderTags();
160
+ notifyChange();
161
+ }
162
+
163
+ function renderTags(): void {
164
+ if (!entry) return;
165
+ // Remove existing pills (everything before the entry input).
166
+ const existing =
167
+ wrapperEl.querySelectorAll<HTMLElement>('.v-tags-input__tag');
168
+ existing.forEach((el) => el.remove());
169
+ // Render in reverse insertion order so each new pill lands directly
170
+ // before the entry input.
171
+ tags.forEach((tag) => {
172
+ const pill = document.createElement('span');
173
+ pill.className = 'v-tags-input__tag';
174
+ pill.dataset.tagValue = tag;
175
+
176
+ const label = document.createElement('span');
177
+ label.className = 'v-tags-input__tag-label';
178
+ label.textContent = tag;
179
+ pill.appendChild(label);
180
+
181
+ const removeBtn = document.createElement('button');
182
+ removeBtn.type = 'button';
183
+ removeBtn.className = 'v-tags-input__tag-remove';
184
+ removeBtn.setAttribute('aria-label', `Remove tag ${tag}`);
185
+ removeBtn.dataset.tagsRemove = '';
186
+ removeBtn.textContent = '×'; // ×
187
+ pill.appendChild(removeBtn);
188
+
189
+ wrapperEl.insertBefore(pill, entry);
190
+ });
191
+ }
192
+
193
+ function isSeparator(event: KeyboardEvent): boolean {
194
+ for (const sep of separators) {
195
+ if (sep.length === 1 && event.key === sep) return true;
196
+ if (sep.length > 1 && event.key === sep) return true;
197
+ }
198
+ return false;
199
+ }
200
+
201
+ function handleKeydown(event: KeyboardEvent): void {
202
+ if (!entry) return;
203
+
204
+ if (isSeparator(event)) {
205
+ const value = entry.value;
206
+ if (value.trim().length > 0) {
207
+ event.preventDefault();
208
+ if (addTag(value)) {
209
+ entry.value = '';
210
+ }
211
+ } else if (event.key === 'Enter') {
212
+ // Empty + Enter — let the surrounding form submit naturally;
213
+ // do NOT preventDefault.
214
+ }
215
+ return;
216
+ }
217
+
218
+ if (
219
+ event.key === 'Backspace' &&
220
+ entry.value === '' &&
221
+ tags.length > 0
222
+ ) {
223
+ event.preventDefault();
224
+ removeTag(tags.length - 1);
225
+ return;
226
+ }
227
+ }
228
+
229
+ function handleClick(event: MouseEvent): void {
230
+ const target = event.target;
231
+ if (!(target instanceof Element)) return;
232
+ const removeBtn =
233
+ target.closest<HTMLButtonElement>('[data-tags-remove]');
234
+ if (!removeBtn) return;
235
+ event.preventDefault();
236
+ const pill = removeBtn.closest<HTMLElement>('.v-tags-input__tag');
237
+ if (!pill) return;
238
+ const idx = Array.from(
239
+ wrapperEl.querySelectorAll<HTMLElement>('.v-tags-input__tag'),
240
+ ).indexOf(pill);
241
+ if (idx === -1) return;
242
+ removeTag(idx);
243
+ // Return focus to the entry input so the keyboard user keeps
244
+ // typing without a manual click — matches Reka UI's TagsInput UX.
245
+ entry?.focus();
246
+ }
247
+
248
+ entry.addEventListener('keydown', handleKeydown);
249
+ wrapperEl.addEventListener('click', handleClick);
250
+
251
+ return function teardown(): void {
252
+ entry.removeEventListener('keydown', handleKeydown);
253
+ wrapperEl.removeEventListener('click', handleClick);
254
+ };
255
+ }