@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,117 @@
1
+ /**
2
+ * Disclosure controller — shared by Accordion and Collapsible per the
3
+ * locked controller-sharing mapping in `CONVENTIONS.md`.
4
+ *
5
+ * The v1 disclosure family is built on top of the **native HTML
6
+ * `<details>` / `<summary>` element pair** per the locked "Use native
7
+ * APIs where applicable" rule. The browser already handles:
8
+ *
9
+ * - Expand / collapse on summary click
10
+ * - The `[open]` attribute reflecting current state
11
+ * - Enter / Space activation when the summary is focused
12
+ * - Tab focus moves to the summary, not the panel content
13
+ * - Initial closed state when the `open` attribute is absent
14
+ *
15
+ * What the browser does NOT provide and we layer on top:
16
+ *
17
+ * - **Single-open coordination** for Accordion mode — when one
18
+ * `<details>` opens, sibling `<details>` elements within the same
19
+ * mount root close. Native `<details>` has no built-in mutual
20
+ * exclusion (the obsolete `name` attribute exists in some browsers
21
+ * but is not reliable cross-browser in 2026).
22
+ * - A **callback** so framework code can react to toggles without
23
+ * each `<details>` wiring its own `@toggle` listener.
24
+ * - A consistent **teardown** lifecycle that matches the rest of the
25
+ * controller archetype.
26
+ *
27
+ * Mount on:
28
+ * - A single `<details>` element for Collapsible (no `singleOpen`
29
+ * needed; the controller is essentially a callback bridge).
30
+ * - A wrapper `<div>` containing multiple `<details>` for Accordion
31
+ * (`singleOpen: true` makes them mutually exclusive).
32
+ */
33
+
34
+ export interface DisclosureConfig {
35
+ /**
36
+ * When `true`, opening any **top-level** `<details>` within the
37
+ * mount root closes all sibling top-level `<details>` elements
38
+ * (Accordion single-open mode). A "top-level" `<details>` here
39
+ * means one whose nearest ancestor `<details>` lies outside the
40
+ * mount root — i.e., a disclosure that belongs to THIS mount, not
41
+ * a nested disclosure inside an item's panel content.
42
+ *
43
+ * When `false`, multiple disclosures can be open simultaneously
44
+ * (Collapsible / Accordion multi-open mode). Default: `false`.
45
+ */
46
+ singleOpen?: boolean;
47
+ /**
48
+ * Called whenever a `<details>` within the mount root toggles.
49
+ * Receives the toggled element and its new open state. Frameworks
50
+ * use this to reflect the change back into reactive state (e.g.,
51
+ * Vue `update:open`). The controller does NOT track open state
52
+ * itself — the DOM is the source of truth via `el.open`.
53
+ */
54
+ onToggle?: (target: HTMLDetailsElement, open: boolean) => void;
55
+ }
56
+
57
+ /**
58
+ * Mount disclosure behavior on `el`. The mount root may be either a
59
+ * single `<details>` (Collapsible) or a wrapper containing multiple
60
+ * `<details>` children (Accordion). Returns a teardown function that
61
+ * removes the listener; the controller owns no other resources.
62
+ *
63
+ * The `toggle` event is captured at the mount root because it bubbles
64
+ * from the originating `<details>`. This means a single listener
65
+ * covers any number of disclosures without per-element wiring.
66
+ */
67
+ export function mountDisclosure(
68
+ el: HTMLElement,
69
+ config: DisclosureConfig = {},
70
+ ): () => void {
71
+ const singleOpen = config.singleOpen ?? false;
72
+ const onToggle = config.onToggle;
73
+
74
+ /**
75
+ * A `<details>` is "top-level" relative to `el` when no other
76
+ * `<details>` lies between it and `el` in the ancestor chain. Only
77
+ * top-level disclosures participate in single-open coordination —
78
+ * nested disclosures are part of the consumer's panel content and
79
+ * must not be closed by the outer mount.
80
+ */
81
+ function isTopLevel(details: HTMLDetailsElement): boolean {
82
+ const parentDetails = details.parentElement?.closest('details') ?? null;
83
+ if (!parentDetails) return true;
84
+ // If the parent `<details>` is outside the mount root, treat
85
+ // `details` as top-level for THIS mount.
86
+ return !el.contains(parentDetails);
87
+ }
88
+
89
+ function handleToggle(event: Event): void {
90
+ const target = event.target;
91
+ if (!(target instanceof HTMLDetailsElement)) return;
92
+ if (!el.contains(target)) return;
93
+
94
+ if (singleOpen && target.open && isTopLevel(target)) {
95
+ // Close every other top-level `<details>` rooted in this mount.
96
+ const allDetails =
97
+ el.querySelectorAll<HTMLDetailsElement>('details');
98
+ allDetails.forEach((other) => {
99
+ if (other === target) return;
100
+ if (!isTopLevel(other)) return;
101
+ if (other.open) other.open = false;
102
+ });
103
+ }
104
+
105
+ onToggle?.(target, target.open);
106
+ }
107
+
108
+ // `toggle` does not bubble in the standards sense — but a capturing
109
+ // listener fires for any descendant `<details>` toggle. Use capture
110
+ // phase so a single mount-root listener covers every `<details>`
111
+ // within the scope.
112
+ el.addEventListener('toggle', handleToggle, true);
113
+
114
+ return function teardown(): void {
115
+ el.removeEventListener('toggle', handleToggle, true);
116
+ };
117
+ }
@@ -0,0 +1,524 @@
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
+ }
@@ -0,0 +1,39 @@
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';