@pienter/ui 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +259 -0
- package/CONVENTIONS.md +297 -385
- package/README.md +3 -18
- package/components/feedback/toast/toast.css +1 -1
- package/components/form/combobox/Combobox.vue +21 -38
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/form/Form.vue +1 -2
- package/components/form/label/label.css +1 -1
- package/components/form/number-field/NumberField.vue +1 -1
- package/components/form/number-field/number-field.css +1 -1
- package/components/form/radio-group/RadioGroup.vue +2 -5
- package/components/form/slider/slider.css +2 -3
- package/components/form/tags-input/tags-input.css +1 -2
- package/components/form/textarea/textarea.css +1 -1
- package/components/layout/accordion/Accordion.vue +6 -27
- package/components/layout/collapsible/Collapsible.vue +9 -19
- package/components/layout/table/table.css +3 -3
- package/components/navigation/pagination/Pagination.vue +1 -1
- package/components/navigation/pagination/PaginationFooter.vue +23 -0
- package/components/navigation/pagination/pagination.css +128 -29
- package/components/navigation/sidebar/Sidebar.vue +326 -84
- package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
- package/components/navigation/sidebar/context.ts +21 -0
- package/components/navigation/sidebar/sidebar.css +346 -78
- package/components/navigation/sidebar/types.ts +13 -1
- package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
- package/components/overlay/command/Command.vue +10 -38
- package/components/overlay/command/command.css +1 -1
- package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
- package/components/overlay/modal/Modal.vue +7 -28
- package/components/overlay/popover/Popover.vue +9 -31
- package/components/overlay/sheet/Sheet.vue +7 -28
- package/components/overlay/tooltip/Tooltip.vue +14 -19
- package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
- package/composables/useDisclosure.ts +113 -0
- package/composables/useEventListener.ts +16 -0
- package/{utils/controllers/menu.ts → composables/useMenu.ts} +66 -102
- package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
- package/package.json +6 -38
- package/styles/0-settings/colors.css +8 -1
- package/styles/0-settings/layout.css +18 -0
- package/styles/0-settings/motion.css +2 -2
- package/styles/0-settings/spacing.css +3 -1
- package/utils/navigation/sidebar.ts +97 -0
- package/utils/validation/form.ts +6 -9
- package/components/action/button/Button.astro +0 -95
- package/components/action/button/IconButton.astro +0 -86
- package/components/display/avatar/Avatar.astro +0 -17
- package/components/display/avatar/AvatarStack.astro +0 -9
- package/components/display/badge/Badge.astro +0 -15
- package/components/display/empty/Empty.astro +0 -9
- package/components/display/icon/Icon.astro +0 -52
- package/components/feedback/alert/Alert.astro +0 -52
- package/components/feedback/progress/Progress.astro +0 -68
- package/components/feedback/skeleton/Skeleton.astro +0 -32
- package/components/feedback/spinner/Spinner.astro +0 -25
- package/components/feedback/toast/Toast.astro +0 -50
- package/components/form/checkbox/Checkbox.astro +0 -79
- package/components/form/date-input/DateInput.astro +0 -105
- package/components/form/form/Form.astro +0 -106
- package/components/form/input-otp/InputOTP.astro +0 -147
- package/components/form/label/Label.astro +0 -13
- package/components/form/number-field/NumberField.astro +0 -142
- package/components/form/radio-group/RadioGroup.astro +0 -105
- package/components/form/select/Select.astro +0 -105
- package/components/form/slider/Slider.astro +0 -205
- package/components/form/switch/Switch.astro +0 -75
- package/components/form/tags-input/TagsInput.astro +0 -153
- package/components/form/text-input/TextInput.astro +0 -84
- package/components/form/textarea/Textarea.astro +0 -86
- package/components/layout/card/Card.astro +0 -13
- package/components/layout/separator/Separator.astro +0 -31
- package/components/layout/table/Table.astro +0 -116
- package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
- package/components/navigation/navbar/Navbar.astro +0 -62
- package/components/navigation/sidebar/Sidebar.astro +0 -132
- package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
- package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
- package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
- package/components/overlay/modal/Modal.astro +0 -66
- package/utils/controllers/disclosure.ts +0 -117
- package/utils/controllers/form.ts +0 -524
- package/utils/controllers/index.ts +0 -39
- package/utils/controllers/number-field.ts +0 -103
- package/utils/controllers/otp.ts +0 -252
- package/utils/controllers/sidebar.ts +0 -610
- package/utils/controllers/slider.ts +0 -336
- package/utils/controllers/tags-input.ts +0 -255
- package/utils/controllers/toast.ts +0 -426
|
@@ -1,336 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,255 +0,0 @@
|
|
|
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
|
-
}
|