@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/CONVENTIONS.md +297 -385
  3. package/README.md +3 -18
  4. package/components/feedback/toast/toast.css +1 -1
  5. package/components/form/combobox/Combobox.vue +21 -38
  6. package/components/form/combobox/combobox.css +1 -1
  7. package/components/form/form/Form.vue +1 -2
  8. package/components/form/label/label.css +1 -1
  9. package/components/form/number-field/NumberField.vue +1 -1
  10. package/components/form/number-field/number-field.css +1 -1
  11. package/components/form/radio-group/RadioGroup.vue +2 -5
  12. package/components/form/slider/slider.css +2 -3
  13. package/components/form/tags-input/tags-input.css +1 -2
  14. package/components/form/textarea/textarea.css +1 -1
  15. package/components/layout/accordion/Accordion.vue +6 -27
  16. package/components/layout/collapsible/Collapsible.vue +9 -19
  17. package/components/layout/table/table.css +3 -3
  18. package/components/navigation/pagination/Pagination.vue +1 -1
  19. package/components/navigation/pagination/PaginationFooter.vue +23 -0
  20. package/components/navigation/pagination/pagination.css +128 -29
  21. package/components/navigation/sidebar/Sidebar.vue +326 -84
  22. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  23. package/components/navigation/sidebar/context.ts +21 -0
  24. package/components/navigation/sidebar/sidebar.css +346 -78
  25. package/components/navigation/sidebar/types.ts +13 -1
  26. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  27. package/components/overlay/command/Command.vue +10 -38
  28. package/components/overlay/command/command.css +1 -1
  29. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  30. package/components/overlay/modal/Modal.vue +7 -28
  31. package/components/overlay/popover/Popover.vue +9 -31
  32. package/components/overlay/sheet/Sheet.vue +7 -28
  33. package/components/overlay/tooltip/Tooltip.vue +14 -19
  34. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  35. package/composables/useDisclosure.ts +113 -0
  36. package/composables/useEventListener.ts +16 -0
  37. package/{utils/controllers/menu.ts → composables/useMenu.ts} +66 -102
  38. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  39. package/package.json +6 -38
  40. package/styles/0-settings/colors.css +8 -1
  41. package/styles/0-settings/layout.css +18 -0
  42. package/styles/0-settings/motion.css +2 -2
  43. package/styles/0-settings/spacing.css +3 -1
  44. package/utils/navigation/sidebar.ts +97 -0
  45. package/utils/validation/form.ts +6 -9
  46. package/components/action/button/Button.astro +0 -95
  47. package/components/action/button/IconButton.astro +0 -86
  48. package/components/display/avatar/Avatar.astro +0 -17
  49. package/components/display/avatar/AvatarStack.astro +0 -9
  50. package/components/display/badge/Badge.astro +0 -15
  51. package/components/display/empty/Empty.astro +0 -9
  52. package/components/display/icon/Icon.astro +0 -52
  53. package/components/feedback/alert/Alert.astro +0 -52
  54. package/components/feedback/progress/Progress.astro +0 -68
  55. package/components/feedback/skeleton/Skeleton.astro +0 -32
  56. package/components/feedback/spinner/Spinner.astro +0 -25
  57. package/components/feedback/toast/Toast.astro +0 -50
  58. package/components/form/checkbox/Checkbox.astro +0 -79
  59. package/components/form/date-input/DateInput.astro +0 -105
  60. package/components/form/form/Form.astro +0 -106
  61. package/components/form/input-otp/InputOTP.astro +0 -147
  62. package/components/form/label/Label.astro +0 -13
  63. package/components/form/number-field/NumberField.astro +0 -142
  64. package/components/form/radio-group/RadioGroup.astro +0 -105
  65. package/components/form/select/Select.astro +0 -105
  66. package/components/form/slider/Slider.astro +0 -205
  67. package/components/form/switch/Switch.astro +0 -75
  68. package/components/form/tags-input/TagsInput.astro +0 -153
  69. package/components/form/text-input/TextInput.astro +0 -84
  70. package/components/form/textarea/Textarea.astro +0 -86
  71. package/components/layout/card/Card.astro +0 -13
  72. package/components/layout/separator/Separator.astro +0 -31
  73. package/components/layout/table/Table.astro +0 -116
  74. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  75. package/components/navigation/navbar/Navbar.astro +0 -62
  76. package/components/navigation/sidebar/Sidebar.astro +0 -132
  77. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  78. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  79. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  80. package/components/overlay/modal/Modal.astro +0 -66
  81. package/utils/controllers/disclosure.ts +0 -117
  82. package/utils/controllers/form.ts +0 -524
  83. package/utils/controllers/index.ts +0 -39
  84. package/utils/controllers/number-field.ts +0 -103
  85. package/utils/controllers/otp.ts +0 -252
  86. package/utils/controllers/sidebar.ts +0 -610
  87. package/utils/controllers/slider.ts +0 -336
  88. package/utils/controllers/tags-input.ts +0 -255
  89. package/utils/controllers/toast.ts +0 -426
@@ -1,524 +0,0 @@
1
- import { focusFirstInvalid } from '../a11y/focus.js';
2
- import { announce } from '../a11y/live-region.js';
3
- import { type Validator } from '../validation/rules.js';
4
- import {
5
- type FormSubmitResponse,
6
- validateFormData,
7
- normalizeFieldErrors,
8
- } from '../validation/form.js';
9
-
10
- /**
11
- * Configuration for `mountForm`. The controller owns submit orchestration:
12
- * intercepts the submit event, runs validators, renders per-field +
13
- * top-level errors, sets `aria-busy` during async submit, focuses the
14
- * first invalid field on failure, and announces success.
15
- *
16
- * `onSubmit` is the consumer's handler — usually an async fetch / SDK
17
- * call. The controller awaits it, then maps the returned
18
- * `FormSubmitResponse` to UI updates. `onSubmit` is only called when
19
- * client-side validation passes; if it fails, the controller renders
20
- * field errors and never calls `onSubmit`.
21
- */
22
- export interface FormConfig {
23
- /** Per-field validators keyed by field `name`. Run on submit. */
24
- validators?: Record<string, Validator[]>;
25
- /**
26
- * Async submit handler. Called only when client validation passes.
27
- * The returned response shape is the placeholder
28
- * `FormSubmitResponse` (see `validation/form.ts`); both Vue and
29
- * Astro Form orchestration consume the identical shape per design
30
- * D13.
31
- */
32
- onSubmit?: (
33
- data: FormData,
34
- ) => Promise<FormSubmitResponse> | FormSubmitResponse;
35
- /**
36
- * Optional observer hook fired after `onSubmit` returns OR after
37
- * client validation fails (in the validation-only path it is
38
- * called with `{ ok: false, fieldErrors }`). Useful for telemetry.
39
- */
40
- onResult?: (result: FormSubmitResponse) => void;
41
- }
42
-
43
- /**
44
- * Mount the form controller on `el`, which MUST be a `<form>` element.
45
- * The controller intercepts the submit event and orchestrates the full
46
- * submit lifecycle:
47
- *
48
- * 1. `event.preventDefault()`. Read `FormData`.
49
- * 2. Run `validateFormData(data, validators)` for client-side errors.
50
- * 3. **If client validation fails**: render per-field errors inside the
51
- * matching form-primitive's `v-field__hint role="alert"` element,
52
- * set `aria-invalid="true"` on each invalid control, render the
53
- * error summary in `v-form__error-summary` (when present), call
54
- * `focusFirstInvalid(el)`, announce the error count assertively,
55
- * then call `onResult` with `{ ok: false, fieldErrors }`. Does NOT
56
- * call `onSubmit`. `onResult` is also called here with
57
- * `{ ok: false, fieldErrors }` so telemetry sees both client and
58
- * server failures.
59
- * 4. **If client validation passes**: clear all field errors, set
60
- * `aria-busy="true"` on the form, disable the submit button, then
61
- * call `await onSubmit(data)`.
62
- * 5. After `onSubmit` returns: clear `aria-busy`, re-enable submit, then
63
- * map the response — render server-side `fieldErrors` and top-level
64
- * `errors`, focus first invalid + announce on failure, render +
65
- * announce `statusMessage` on success. Call `onResult(response)`
66
- * last so consumers can act on `response.redirect`.
67
- *
68
- * Returns a teardown function that removes the submit listener and
69
- * clears any in-progress busy state. Called from Vue's
70
- * `onBeforeUnmount` or an Astro page-level cleanup. The non-stateful
71
- * controller signature is the canonical default per CONVENTIONS
72
- * (mount → teardown without runtime imperative methods).
73
- */
74
- export function mountForm(
75
- el: HTMLFormElement,
76
- config: FormConfig = {},
77
- ): () => void {
78
- const validators = config.validators;
79
- const onSubmit = config.onSubmit;
80
- const onResult = config.onResult;
81
-
82
- let busy = false;
83
- let tornDown = false;
84
-
85
- async function handleSubmit(event: SubmitEvent): Promise<void> {
86
- event.preventDefault();
87
- if (busy) return; // re-entrancy guard
88
-
89
- const formData = new FormData(el);
90
-
91
- // 1. Client-side validation.
92
- const clientErrors = validateFormData(formData, validators);
93
- const clientErrorCount = Object.keys(clientErrors).length;
94
-
95
- if (clientErrorCount > 0) {
96
- applyValidationFailure(el, clientErrors, [], null);
97
- const result: FormSubmitResponse = {
98
- ok: false,
99
- fieldErrors: clientErrors,
100
- };
101
- onResult?.(result);
102
- return;
103
- }
104
-
105
- // 2. Client validation passed — clear errors, enter busy state, call onSubmit.
106
- clearAllErrors(el);
107
-
108
- if (!onSubmit) {
109
- // No submit handler — nothing else to do. Do not call onResult here
110
- // because there's no result to report.
111
- return;
112
- }
113
-
114
- setBusy(el, true);
115
- busy = true;
116
-
117
- let response: FormSubmitResponse;
118
- try {
119
- response = await onSubmit(formData);
120
- } catch (err) {
121
- // Translate thrown errors into a generic top-level error response so
122
- // the UI still surfaces something. Consumers wanting structured
123
- // error handling should resolve their promise with `{ ok: false, ... }`
124
- // rather than throwing.
125
- response = {
126
- ok: false,
127
- errors: [err instanceof Error ? err.message : 'Submit failed'],
128
- };
129
- } finally {
130
- setBusy(el, false);
131
- busy = false;
132
- }
133
-
134
- // If teardown ran while `onSubmit` was in flight, abort: the consumer
135
- // is unmounting / re-mounting and the DOM should not be mutated.
136
- if (tornDown) return;
137
-
138
- // 3. Map response to UI.
139
- if (response.ok) {
140
- clearAllErrors(el);
141
- renderStatusMessage(el, response.statusMessage);
142
- if (response.statusMessage) {
143
- announce(response.statusMessage, 'polite');
144
- }
145
- } else {
146
- const fieldErrors = normalizeFieldErrors(response.fieldErrors);
147
- const topErrors = response.errors ?? [];
148
- applyValidationFailure(
149
- el,
150
- fieldErrors,
151
- topErrors,
152
- response.statusMessage ?? null,
153
- );
154
- }
155
-
156
- onResult?.(response);
157
- }
158
-
159
- el.addEventListener('submit', handleSubmit);
160
-
161
- return function teardown(): void {
162
- el.removeEventListener('submit', handleSubmit);
163
- tornDown = true;
164
- if (busy) {
165
- setBusy(el, false);
166
- busy = false;
167
- }
168
- // Clean up all controller-injected DOM so the form returns to its
169
- // pre-mount shape. Important for HMR re-mount and test
170
- // mount→teardown→remount cycles where stale nodes / managed-state
171
- // markers would otherwise leak between mounts.
172
- cleanupControllerDom(el);
173
- };
174
- }
175
-
176
- /**
177
- * Remove every piece of DOM the controller may have injected or marked,
178
- * leaving consumer-supplied static markup untouched. Mirrors the
179
- * inverse of {@link applyFieldErrors}, {@link renderErrorSummary},
180
- * {@link renderStatusMessage}, and {@link setBusy}.
181
- *
182
- * The principle: after `teardown()` runs, the form should be
183
- * indistinguishable from its state before `mountForm` ran.
184
- */
185
- function cleanupControllerDom(el: HTMLFormElement): void {
186
- // Remove controller-injected error lists, summary, and status nodes.
187
- el.querySelectorAll<HTMLElement>(
188
- '[data-v-form-errors], [data-v-form-summary], [data-v-form-status]',
189
- ).forEach((node) => {
190
- // Walk up to the field wrapper before removing so we can clear a
191
- // wrapper-level `data-status="error"` that has no other error list
192
- // backing it (mirrors the cleanup branch in applyFieldErrors).
193
- const wrapper = node.closest<HTMLElement>('.v-field');
194
- node.remove();
195
- if (wrapper && wrapper.dataset.status === 'error') {
196
- const stillHasErrors = wrapper.querySelector<HTMLElement>(
197
- '.v-field__hint[role="alert"]',
198
- );
199
- if (!stillHasErrors) delete wrapper.dataset.status;
200
- }
201
- });
202
-
203
- // Strip `data-v-form-managed-invalid` markers and the `aria-invalid`
204
- // attribute they track.
205
- el.querySelectorAll<HTMLElement>(
206
- '[data-v-form-managed-invalid="true"]',
207
- ).forEach((node) => {
208
- node.removeAttribute('aria-invalid');
209
- delete node.dataset.vFormManagedInvalid;
210
- });
211
-
212
- // Strip `data-v-form-managed-described-by` markers, removing the
213
- // tracked id from `aria-describedby` (and the attribute itself when
214
- // it ends up empty).
215
- el.querySelectorAll<HTMLElement>(
216
- '[data-v-form-managed-described-by]',
217
- ).forEach((node) => {
218
- const managedId = node.dataset.vFormManagedDescribedBy;
219
- if (managedId) {
220
- const existing = node.getAttribute('aria-describedby');
221
- if (existing) {
222
- const remaining = existing
223
- .split(/\s+/)
224
- .filter((id) => id && id !== managedId);
225
- if (remaining.length > 0) {
226
- node.setAttribute('aria-describedby', remaining.join(' '));
227
- } else {
228
- node.removeAttribute('aria-describedby');
229
- }
230
- }
231
- }
232
- delete node.dataset.vFormManagedDescribedBy;
233
- });
234
-
235
- // Re-enable any submit button we disabled via the busy lifecycle.
236
- el.querySelectorAll<HTMLButtonElement>(
237
- '[data-v-form-managed-disabled="true"]',
238
- ).forEach((node) => {
239
- node.disabled = false;
240
- delete node.dataset.vFormManagedDisabled;
241
- });
242
-
243
- // Clear `aria-busy` if we're the ones that set it.
244
- if (el.getAttribute('aria-busy') === 'true') {
245
- el.removeAttribute('aria-busy');
246
- }
247
- }
248
-
249
- /* ------------------------------------------------------------------ */
250
- /* Display helpers — private to the controller. */
251
- /* ------------------------------------------------------------------ */
252
-
253
- /**
254
- * Render per-field errors + top-level error summary, mark invalid
255
- * controls, focus first invalid, and announce the error count. Used by
256
- * both the client-validation-fails path and the server-error response
257
- * path.
258
- */
259
- function applyValidationFailure(
260
- el: HTMLFormElement,
261
- fieldErrors: Record<string, string[]>,
262
- topErrors: string[],
263
- serverStatusMessage: string | null,
264
- ): void {
265
- // Clear any prior status message — error response should not coexist
266
- // with success chrome.
267
- renderStatusMessage(el, undefined);
268
-
269
- // Clear field errors that are no longer present, then apply the new set.
270
- applyFieldErrors(el, fieldErrors);
271
-
272
- // Render the top-level summary (combining top-level errors + per-field
273
- // counts). The current Form template renders `.v-form__error-summary`
274
- // when `errors.length > 0` is true; the controller populates it
275
- // imperatively here, creating the summary node if needed.
276
- renderErrorSummary(el, topErrors);
277
-
278
- // Focus the first invalid field.
279
- focusFirstInvalid(el);
280
-
281
- // Announce a summary count assertively. Server message (if any) takes
282
- // priority over a generic count.
283
- const totalCount = Object.keys(fieldErrors).length + topErrors.length;
284
- if (serverStatusMessage) {
285
- announce(serverStatusMessage, 'assertive');
286
- } else if (totalCount > 0) {
287
- const message =
288
- totalCount === 1
289
- ? '1 error found. Please review the form.'
290
- : `${totalCount} errors found. Please review the form.`;
291
- announce(message, 'assertive');
292
- }
293
- }
294
-
295
- /**
296
- * Apply per-field errors to the matching form-primitive's `v-field`
297
- * wrapper. Looks up the control by `name`, walks up to its `v-field`
298
- * ancestor, and rewrites the error list inside the wrapper following
299
- * the locked `<ul class="v-field__hint" role="alert">` pattern.
300
- *
301
- * Fields not present in the new map have their previous controller-
302
- * rendered errors cleared (via the `[data-v-form-errors]` marker on the
303
- * element the controller injected, so consumer-supplied static error
304
- * markup is left alone).
305
- */
306
- function applyFieldErrors(
307
- el: HTMLFormElement,
308
- fieldErrors: Record<string, string[]>,
309
- ): void {
310
- // Clear all previously controller-rendered field errors first.
311
- const previouslyRendered = el.querySelectorAll<HTMLElement>(
312
- '[data-v-form-errors]',
313
- );
314
- previouslyRendered.forEach((node) => {
315
- const wrapper = node.closest<HTMLElement>('.v-field');
316
- node.remove();
317
- if (wrapper) {
318
- // Only clear data-status="error" if it was set by us (not by static
319
- // consumer-supplied error markup). The marker convention: if a
320
- // wrapper has no other error list left after we remove ours,
321
- // clear data-status="error".
322
- const stillHasErrors = wrapper.querySelector<HTMLElement>(
323
- '.v-field__hint[role="alert"]',
324
- );
325
- if (!stillHasErrors && wrapper.dataset.status === 'error') {
326
- delete wrapper.dataset.status;
327
- }
328
- }
329
- });
330
-
331
- // Clear aria-invalid + controller-managed aria-describedby additions on
332
- // all named controls (we re-apply below).
333
- const namedControls = el.querySelectorAll<HTMLElement>(
334
- 'input[name], select[name], textarea[name]',
335
- );
336
- namedControls.forEach((control) => {
337
- if (control.dataset.vFormManagedInvalid === 'true') {
338
- control.removeAttribute('aria-invalid');
339
- delete control.dataset.vFormManagedInvalid;
340
- }
341
- const managedId = control.dataset.vFormManagedDescribedBy;
342
- if (managedId) {
343
- const existing = control.getAttribute('aria-describedby');
344
- if (existing) {
345
- const remaining = existing
346
- .split(/\s+/)
347
- .filter((id) => id && id !== managedId);
348
- if (remaining.length > 0) {
349
- control.setAttribute(
350
- 'aria-describedby',
351
- remaining.join(' '),
352
- );
353
- } else {
354
- control.removeAttribute('aria-describedby');
355
- }
356
- }
357
- delete control.dataset.vFormManagedDescribedBy;
358
- }
359
- });
360
-
361
- // Apply new field errors.
362
- for (const [name, errors] of Object.entries(fieldErrors)) {
363
- if (errors.length === 0) continue;
364
- const control = el.querySelector<HTMLElement>(
365
- `[name="${CSS.escape(name)}"]`,
366
- );
367
- if (!control) continue;
368
-
369
- control.setAttribute('aria-invalid', 'true');
370
- control.dataset.vFormManagedInvalid = 'true';
371
-
372
- const wrapper = control.closest<HTMLElement>('.v-field');
373
- if (wrapper) {
374
- wrapper.dataset.status = 'error';
375
-
376
- // Build the error list with the locked pattern.
377
- const controlId = control.id || '';
378
- const errorsId = controlId ? `${controlId}-errors` : '';
379
- const ul = document.createElement('ul');
380
- ul.className = 'v-field__hint';
381
- ul.setAttribute('role', 'alert');
382
- if (errorsId) ul.id = errorsId;
383
- ul.dataset.vFormErrors = 'true';
384
- for (const errMsg of errors) {
385
- const li = document.createElement('li');
386
- li.textContent = errMsg;
387
- ul.appendChild(li);
388
- }
389
- wrapper.appendChild(ul);
390
-
391
- // Update aria-describedby to include the errors ID.
392
- if (errorsId) {
393
- const existingDescribedBy =
394
- control.getAttribute('aria-describedby');
395
- const ids = existingDescribedBy
396
- ? existingDescribedBy.split(/\s+/).filter(Boolean)
397
- : [];
398
- if (!ids.includes(errorsId)) {
399
- ids.push(errorsId);
400
- control.setAttribute('aria-describedby', ids.join(' '));
401
- control.dataset.vFormManagedDescribedBy = errorsId;
402
- }
403
- }
404
- }
405
- }
406
- }
407
-
408
- /**
409
- * Render or remove the form-level error summary at the top of the
410
- * form. If `topErrors` is empty AND there are no field errors needing a
411
- * summary nudge, the summary is removed entirely.
412
- */
413
- function renderErrorSummary(el: HTMLFormElement, topErrors: string[]): void {
414
- // Only manage controller-owned summaries (`data-v-form-summary="true"`).
415
- // Static consumer-rendered summaries (the `.v-form__error-summary`
416
- // template-time block from Form.astro's `errors` prop) are left alone —
417
- // the consumer is responsible for clearing them on a re-render.
418
- let summary = el.querySelector<HTMLElement>(
419
- '.v-form__error-summary[data-v-form-summary="true"]',
420
- );
421
-
422
- if (topErrors.length === 0) {
423
- if (summary) summary.remove();
424
- return;
425
- }
426
-
427
- if (!summary) {
428
- summary = document.createElement('div');
429
- summary.className = 'v-form__error-summary';
430
- summary.setAttribute('role', 'alert');
431
- summary.setAttribute('aria-live', 'assertive');
432
- summary.dataset.vFormSummary = 'true';
433
- el.prepend(summary);
434
- }
435
-
436
- // Rebuild contents.
437
- summary.innerHTML = '';
438
- const title = document.createElement('p');
439
- title.className = 'v-form__error-summary-title';
440
- title.textContent = 'There was a problem';
441
- summary.appendChild(title);
442
-
443
- const list = document.createElement('ul');
444
- list.className = 'v-form__error-summary-list';
445
- for (const errMsg of topErrors) {
446
- const li = document.createElement('li');
447
- li.textContent = errMsg;
448
- list.appendChild(li);
449
- }
450
- summary.appendChild(list);
451
- }
452
-
453
- /**
454
- * Render or remove the form-level success status region. Mirrors the
455
- * Form template's `.v-form__status` element with `aria-live="polite"`.
456
- */
457
- function renderStatusMessage(
458
- el: HTMLFormElement,
459
- message: string | undefined,
460
- ): void {
461
- // Only manage controller-owned status (`data-v-form-status="true"`).
462
- // Static consumer-rendered status regions are left alone.
463
- let status = el.querySelector<HTMLElement>(
464
- '.v-form__status[data-v-form-status="true"]',
465
- );
466
-
467
- if (!message) {
468
- if (status) status.remove();
469
- return;
470
- }
471
-
472
- if (!status) {
473
- status = document.createElement('div');
474
- status.className = 'v-form__status';
475
- status.setAttribute('aria-live', 'polite');
476
- status.dataset.vFormStatus = 'true';
477
-
478
- // Insert before `.v-form__actions` if present so the layout matches
479
- // the template; otherwise append.
480
- const actions = el.querySelector<HTMLElement>('.v-form__actions');
481
- if (actions) {
482
- el.insertBefore(status, actions);
483
- } else {
484
- el.appendChild(status);
485
- }
486
- }
487
- status.textContent = message;
488
- }
489
-
490
- /**
491
- * Clear all controller-rendered errors (per-field + summary) and the
492
- * status region. Called when the form transitions into the busy state
493
- * (so prior errors don't linger while the consumer's onSubmit runs).
494
- */
495
- function clearAllErrors(el: HTMLFormElement): void {
496
- applyFieldErrors(el, {});
497
- renderErrorSummary(el, []);
498
- }
499
-
500
- /**
501
- * Set or clear the form's busy state. Sets `aria-busy="true"` (per the
502
- * boolean-attr-only-when-truthy rule) and disables the implicit submit
503
- * button so consumers can't double-submit. The submit button is
504
- * identified as `<button type="submit">` inside the form.
505
- */
506
- function setBusy(el: HTMLFormElement, busyState: boolean): void {
507
- if (busyState) {
508
- el.setAttribute('aria-busy', 'true');
509
- } else {
510
- el.removeAttribute('aria-busy');
511
- }
512
- const submitBtn = el.querySelector<HTMLButtonElement>(
513
- 'button[type="submit"]',
514
- );
515
- if (submitBtn) {
516
- if (busyState) {
517
- submitBtn.disabled = true;
518
- submitBtn.dataset.vFormManagedDisabled = 'true';
519
- } else if (submitBtn.dataset.vFormManagedDisabled === 'true') {
520
- submitBtn.disabled = false;
521
- delete submitBtn.dataset.vFormManagedDisabled;
522
- }
523
- }
524
- }
@@ -1,39 +0,0 @@
1
- // Controllers are added as components are built (Phase 1+).
2
- // See packages/ui/CONVENTIONS.md § Controller mount-on-element pattern.
3
- export { mountDialog } from './dialog.js';
4
- export type { DialogControl, DialogConfig } from './dialog.js';
5
-
6
- export { mountSidebar, isHrefActive, deriveGroupKey } from './sidebar.js';
7
- export type { SidebarControl, SidebarConfig } from './sidebar.js';
8
-
9
- export { mountForm } from './form.js';
10
- export type { FormConfig } from './form.js';
11
-
12
- export { mountNumberField } from './number-field.js';
13
- export type { NumberFieldConfig } from './number-field.js';
14
-
15
- export { mountToast, toast } from './toast.js';
16
- export type {
17
- ToastTone,
18
- ToastOptions,
19
- ToastConfig,
20
- ToastPosition,
21
- } from './toast.js';
22
-
23
- export { mountDisclosure } from './disclosure.js';
24
- export type { DisclosureConfig } from './disclosure.js';
25
-
26
- export { mountPopover } from './popover.js';
27
- export type { PopoverControl, PopoverConfig, Placement } from './popover.js';
28
-
29
- export { mountMenu } from './menu.js';
30
- export type { MenuConfig } from './menu.js';
31
-
32
- export { mountTagsInput } from './tags-input.js';
33
- export type { TagsInputConfig } from './tags-input.js';
34
-
35
- export { mountInputOTP } from './otp.js';
36
- export type { InputOTPConfig } from './otp.js';
37
-
38
- export { mountSlider } from './slider.js';
39
- export type { SliderConfig } from './slider.js';
@@ -1,103 +0,0 @@
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
- }