@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,105 @@
1
+ ---
2
+ import './date-input.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ id?: string;
7
+ label: string;
8
+ name?: string;
9
+ /**
10
+ * Native input type. `month` and `week` are intentionally out of
11
+ * scope for v1 because their browser support is uneven (notably
12
+ * Safari) — see CONVENTIONS DateInput audit entry.
13
+ */
14
+ type?: 'date' | 'time' | 'datetime-local';
15
+ /**
16
+ * Current ISO-formatted value. The exact shape depends on `type`:
17
+ * - `date` → `YYYY-MM-DD`
18
+ * - `time` → `HH:MM` (or `HH:MM:SS` when `step` enables seconds)
19
+ * - `datetime-local` → `YYYY-MM-DDTHH:MM`
20
+ */
21
+ value?: string;
22
+ /** Lower bound, in the same ISO shape as `value`. Forwarded to native `min`. */
23
+ min?: string;
24
+ /** Upper bound, in the same ISO shape as `value`. Forwarded to native `max`. */
25
+ max?: string;
26
+ /**
27
+ * Native step granularity. Useful for `type="time"`:
28
+ * `step="60"` (default) gives minute precision; `step="1"`
29
+ * exposes seconds in the picker.
30
+ */
31
+ step?: number | string;
32
+ required?: boolean;
33
+ disabled?: boolean;
34
+ hint?: string;
35
+ errors?: string[];
36
+ status?: 'error' | 'success';
37
+ }
38
+
39
+ const {
40
+ id,
41
+ label,
42
+ name,
43
+ type = 'date',
44
+ value = '',
45
+ min,
46
+ max,
47
+ step,
48
+ required = false,
49
+ disabled = false,
50
+ hint,
51
+ errors = [],
52
+ status,
53
+ ...rest
54
+ } = Astro.props;
55
+
56
+ const inputId = id ?? generateId('input');
57
+ const hintId = `${inputId}-hint`;
58
+ const errorsId = `${inputId}-errors`;
59
+
60
+ const hasErrors = errors.length > 0;
61
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
62
+
63
+ const describedByIds: string[] = [];
64
+ if (hint) describedByIds.push(hintId);
65
+ if (hasErrors) describedByIds.push(errorsId);
66
+ const describedBy =
67
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
68
+ ---
69
+
70
+ <div class="pui-field" data-status={computedStatus}>
71
+ <label class="pui-field__label" for={inputId}>
72
+ {label}
73
+ </label>
74
+ <input
75
+ class="pui-input"
76
+ id={inputId}
77
+ type={type}
78
+ name={name}
79
+ value={value}
80
+ min={min}
81
+ max={max}
82
+ step={step}
83
+ required={required}
84
+ disabled={disabled}
85
+ aria-invalid={hasErrors ? 'true' : undefined}
86
+ aria-describedby={describedBy}
87
+ {...rest}
88
+ />
89
+ {
90
+ hint && (
91
+ <p id={hintId} class="pui-field__hint">
92
+ {hint}
93
+ </p>
94
+ )
95
+ }
96
+ {
97
+ hasErrors && (
98
+ <ul id={errorsId} class="pui-field__hint" role="alert">
99
+ {errors.map((error) => (
100
+ <li>{error}</li>
101
+ ))}
102
+ </ul>
103
+ )
104
+ }
105
+ </div>
@@ -0,0 +1,121 @@
1
+ <template>
2
+ <div class="pui-field" :data-status="computedStatus">
3
+ <label class="pui-field__label" :for="inputId">{{ label }}</label>
4
+ <input
5
+ v-bind="$attrs"
6
+ :id="inputId"
7
+ class="pui-input"
8
+ :type="type"
9
+ :name="name"
10
+ :value="modelValue"
11
+ :min="min"
12
+ :max="max"
13
+ :step="step"
14
+ :required="required"
15
+ :disabled="disabled"
16
+ :aria-invalid="hasErrors ? 'true' : undefined"
17
+ :aria-describedby="describedBy"
18
+ @input="
19
+ emit(
20
+ 'update:modelValue',
21
+ ($event.target as HTMLInputElement).value,
22
+ )
23
+ "
24
+ @blur="emit('blur')"
25
+ />
26
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
27
+ <ul
28
+ v-if="errors.length"
29
+ :id="errorsId"
30
+ class="pui-field__hint"
31
+ role="alert"
32
+ >
33
+ <li v-for="error in errors" :key="error">{{ error }}</li>
34
+ </ul>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed } from 'vue';
40
+ import { generateId } from '../../../utils/a11y/id.js';
41
+
42
+ defineOptions({ inheritAttrs: false });
43
+
44
+ const props = withDefaults(
45
+ defineProps<{
46
+ id?: string;
47
+ label: string;
48
+ /**
49
+ * Current ISO-formatted value. The exact shape depends on `type`:
50
+ * - `date` → `YYYY-MM-DD`
51
+ * - `time` → `HH:MM` (or `HH:MM:SS` when `step` enables seconds)
52
+ * - `datetime-local` → `YYYY-MM-DDTHH:MM`
53
+ * An empty string represents an empty input.
54
+ */
55
+ modelValue: string;
56
+ name?: string;
57
+ /**
58
+ * Native input type. `month` and `week` are intentionally out of
59
+ * scope for v1 because their browser support is uneven (notably
60
+ * Safari) — see ./AUDIT.md.
61
+ */
62
+ type?: 'date' | 'time' | 'datetime-local';
63
+ /** Lower bound, in the same ISO shape as `modelValue`. Forwarded to native `min`. */
64
+ min?: string;
65
+ /** Upper bound, in the same ISO shape as `modelValue`. Forwarded to native `max`. */
66
+ max?: string;
67
+ /**
68
+ * Native step granularity. Useful for `type="time"`:
69
+ * `step="60"` (default) gives minute precision; `step="1"`
70
+ * exposes seconds in the picker.
71
+ */
72
+ step?: number | string;
73
+ required?: boolean;
74
+ disabled?: boolean;
75
+ hint?: string;
76
+ errors?: string[];
77
+ status?: 'error' | 'success';
78
+ }>(),
79
+ {
80
+ id: undefined,
81
+ name: undefined,
82
+ type: 'date',
83
+ min: undefined,
84
+ max: undefined,
85
+ step: undefined,
86
+ required: false,
87
+ disabled: false,
88
+ hint: undefined,
89
+ errors: () => [],
90
+ status: undefined,
91
+ },
92
+ );
93
+
94
+ const emit = defineEmits<{
95
+ 'update:modelValue': [value: string];
96
+ blur: [];
97
+ }>();
98
+
99
+ const inputId = props.id ?? generateId('input');
100
+ const hintId = `${inputId}-hint`;
101
+ const errorsId = `${inputId}-errors`;
102
+
103
+ const hasErrors = computed(() => props.errors.length > 0);
104
+
105
+ const computedStatus = computed(() => {
106
+ if (props.status) return props.status;
107
+ if (hasErrors.value) return 'error';
108
+ return undefined;
109
+ });
110
+
111
+ const describedBy = computed(() => {
112
+ const ids: string[] = [];
113
+ if (props.hint) ids.push(hintId);
114
+ if (hasErrors.value) ids.push(errorsId);
115
+ return ids.length > 0 ? ids.join(' ') : undefined;
116
+ });
117
+ </script>
118
+
119
+ <style>
120
+ @import './date-input.css';
121
+ </style>
@@ -0,0 +1,19 @@
1
+ @layer components {
2
+ /* DateInput reuses the shared `.pui-input` rules (sizing, padding,
3
+ * border, focus ring, disabled, status-driven recolour) that live
4
+ * alongside `.pui-textarea` in `components/form/text-input/text-input.css`
5
+ * because the visual shell is identical to TextInput — only the
6
+ * native input `type` differs (`date` / `time` / `datetime-local`
7
+ * vs `text`). The browser owns the picker UI; we just style the
8
+ * input chrome consistently with the rest of the form-primitive
9
+ * scaffold.
10
+ *
11
+ * This file is intentionally a near-empty placeholder following the
12
+ * Textarea pattern — every form primitive keeps its own CSS file
13
+ * for layered consistency, even when there are no component-specific
14
+ * rules. When the fifth-plus form primitive ships and the shared
15
+ * `.pui-input` rules outgrow `text-input.css`, they'll move into a
16
+ * dedicated `form-field.css` per the deferred-extraction note in
17
+ * CONVENTIONS.
18
+ */
19
+ }
@@ -0,0 +1,106 @@
1
+ ---
2
+ import './form.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ /** Stable form ID — used by the consumer's `mountForm` script. */
7
+ id?: string;
8
+ submitLabel?: string;
9
+ busy?: boolean;
10
+ /** Top-level errors rendered in the error summary. */
11
+ errors?: string[];
12
+ /** Success/status message rendered in the polite live region. */
13
+ statusMessage?: string;
14
+ }
15
+
16
+ const {
17
+ id,
18
+ submitLabel = 'Submit',
19
+ busy = false,
20
+ errors = [],
21
+ statusMessage,
22
+ } = Astro.props;
23
+
24
+ const formId = id ?? generateId('form');
25
+ ---
26
+
27
+ <form
28
+ class="pui-form"
29
+ id={formId}
30
+ data-pui-form
31
+ novalidate
32
+ aria-busy={busy ? 'true' : undefined}
33
+ >
34
+ {
35
+ errors.length > 0 && (
36
+ <div class="pui-form__error-summary" role="alert" aria-live="assertive">
37
+ <p class="pui-form__error-summary-title">There was a problem</p>
38
+ <ul class="pui-form__error-summary-list">
39
+ {errors.map((error) => (
40
+ <li>{error}</li>
41
+ ))}
42
+ </ul>
43
+ </div>
44
+ )
45
+ }
46
+
47
+ <slot />
48
+
49
+ {
50
+ statusMessage && (
51
+ <div class="pui-form__status" aria-live="polite">
52
+ {statusMessage}
53
+ </div>
54
+ )
55
+ }
56
+
57
+ <div class="pui-form__actions">
58
+ <slot name="actions">
59
+ <button
60
+ class="pui-btn"
61
+ data-variant="primary"
62
+ type="submit"
63
+ disabled={busy}
64
+ >
65
+ {submitLabel}
66
+ </button>
67
+ </slot>
68
+ </div>
69
+ </form>
70
+
71
+ {
72
+ /*
73
+ Form.astro renders chrome only — it deliberately does NOT auto-mount
74
+ `mountForm`. The controller takes `validators` and an async `onSubmit`
75
+ handler, both of which are functions that can't be passed as Astro
76
+ props. Consumers wire the controller themselves from a `<script>`
77
+ block:
78
+
79
+ ---
80
+ import Form from '@pienter/ui/components/Form.astro';
81
+ import TextInput from '@pienter/ui/components/TextInput.astro';
82
+ ---
83
+ <Form id="signup" submitLabel="Create account">
84
+ <TextInput label="Email" name="email" type="email" />
85
+ </Form>
86
+ <script>
87
+ import { mountForm } from '@pienter/ui/utils/controllers/form.js';
88
+ import { isRequired, isEmail } from '@pienter/ui/utils/validation/rules.js';
89
+ const el = document.getElementById('signup');
90
+ if (el instanceof HTMLFormElement) {
91
+ mountForm(el, {
92
+ validators: { email: [isRequired(), isEmail()] },
93
+ onSubmit: async (data) => {
94
+ const res = await fetch('/api/signup', { method: 'POST', body: data });
95
+ if (res.ok) return { ok: true, statusMessage: 'Account created' };
96
+ const body = await res.json();
97
+ return { ok: false, fieldErrors: body.fieldErrors, errors: body.errors };
98
+ },
99
+ });
100
+ }
101
+ </script>
102
+
103
+ This explicit mount pattern is the locked Astro Form contract per the
104
+ Form audit entry in CONVENTIONS.md.
105
+ */
106
+ }
@@ -0,0 +1,181 @@
1
+ <template>
2
+ <form
3
+ ref="formRef"
4
+ class="pui-form"
5
+ novalidate
6
+ :aria-busy="busy ? 'true' : undefined"
7
+ @submit.prevent="handleSubmit"
8
+ >
9
+ <div
10
+ v-if="topErrors.length > 0"
11
+ class="pui-form__error-summary"
12
+ role="alert"
13
+ aria-live="assertive"
14
+ >
15
+ <p class="pui-form__error-summary-title">There was a problem</p>
16
+ <ul class="pui-form__error-summary-list">
17
+ <li v-for="error in topErrors" :key="error">{{ error }}</li>
18
+ </ul>
19
+ </div>
20
+
21
+ <slot />
22
+
23
+ <div v-if="statusMessage" class="pui-form__status" aria-live="polite">
24
+ {{ statusMessage }}
25
+ </div>
26
+
27
+ <div class="pui-form__actions">
28
+ <slot name="actions">
29
+ <button
30
+ class="pui-btn"
31
+ data-variant="primary"
32
+ type="submit"
33
+ :disabled="busy"
34
+ >
35
+ {{ submitLabel }}
36
+ </button>
37
+ </slot>
38
+ </div>
39
+ </form>
40
+ </template>
41
+
42
+ <script setup lang="ts">
43
+ import { computed, ref, watch } from 'vue';
44
+ import { focusFirstInvalid } from '../../../utils/a11y/focus.js';
45
+ import { announce } from '../../../utils/a11y/live-region.js';
46
+ import { type Validator } from '../../../utils/validation/rules.js';
47
+ import {
48
+ validateFormData,
49
+ normalizeFieldErrors,
50
+ } from '../../../utils/validation/form.js';
51
+
52
+ const props = withDefaults(
53
+ defineProps<{
54
+ submitLabel?: string;
55
+ busy?: boolean;
56
+ /** Top-level errors not tied to a specific field. */
57
+ errors?: string[];
58
+ /** Server-side per-field errors. Consumers SHALL replace the object reference rather than mutate in place. */
59
+ fieldErrors?: Record<string, string | string[]>;
60
+ /** Success/status message; announced via `announce()` when set or changed. */
61
+ statusMessage?: string;
62
+ /** Per-field client-side validators run on submit. */
63
+ validators?: Record<string, Validator[]>;
64
+ }>(),
65
+ {
66
+ submitLabel: 'Submit',
67
+ busy: false,
68
+ errors: () => [],
69
+ fieldErrors: () => ({}),
70
+ statusMessage: undefined,
71
+ validators: () => ({}),
72
+ },
73
+ );
74
+
75
+ const emit = defineEmits<{
76
+ /** Fired AFTER client validation passes; consumer handles the async submit. */
77
+ submit: [data: FormData];
78
+ /** Fired when client validation fails; useful for telemetry / logging. */
79
+ 'validation-error': [errors: Record<string, string[]>];
80
+ }>();
81
+
82
+ const formRef = ref<HTMLFormElement | null>(null);
83
+
84
+ // Internal client-validation errors. Merged with server-supplied
85
+ // `fieldErrors` for display via the form-primitive children's own
86
+ // `errors` prop pattern (consumer wires them up by name); the summary
87
+ // above renders only top-level errors.
88
+ const clientFieldErrors = ref<Record<string, string[]>>({});
89
+
90
+ // Combined top-level errors: consumer-supplied + a synthesized count
91
+ // summary when client validation fails. Keep simple for v1: just
92
+ // surface consumer-supplied; the announcement carries the count.
93
+ const topErrors = computed<string[]>(() => props.errors ?? []);
94
+
95
+ // Combined field errors (server + client) — exposed via provide for any
96
+ // future field-aware children, though the current TextInput pattern is
97
+ // for consumers to wire `:errors` themselves. Kept here for the
98
+ // announce + focus-first-invalid logic.
99
+ const allFieldErrors = computed<Record<string, string[]>>(() => {
100
+ const server = normalizeFieldErrors(props.fieldErrors);
101
+ // Server errors win when both exist for the same field.
102
+ return { ...clientFieldErrors.value, ...server };
103
+ });
104
+
105
+ function handleSubmit(): void {
106
+ const formEl = formRef.value;
107
+ if (!formEl) return;
108
+
109
+ const formData = new FormData(formEl);
110
+
111
+ // Run client-side validators.
112
+ const errors = validateFormData(formData, props.validators);
113
+ clientFieldErrors.value = errors;
114
+
115
+ const errorCount = Object.keys(errors).length;
116
+ if (errorCount > 0) {
117
+ emit('validation-error', errors);
118
+ // Wait a tick for the consumer to reflect errors back into the
119
+ // child form-primitives' `:errors` prop (which sets aria-invalid),
120
+ // then move focus + announce.
121
+ requestAnimationFrame(() => {
122
+ focusFirstInvalid(formEl);
123
+ const message =
124
+ errorCount === 1
125
+ ? '1 error found. Please review the form.'
126
+ : `${errorCount} errors found. Please review the form.`;
127
+ announce(message, 'assertive');
128
+ });
129
+ return;
130
+ }
131
+
132
+ // Validation passed. Clear any lingering client-side errors from a
133
+ // prior failed submit so child form-primitives don't keep displaying
134
+ // them while the consumer's async submit runs (mirrors the Astro
135
+ // controller's `clearAllErrors(el)` between client-pass and onSubmit).
136
+ clientFieldErrors.value = {};
137
+
138
+ // Surface FormData to the consumer's submit handler; consumer manages
139
+ // the busy/response lifecycle via the `busy`, `errors`, `fieldErrors`,
140
+ // and `statusMessage` props.
141
+ emit('submit', formData);
142
+ }
143
+
144
+ // Announce the success / status message whenever it changes. Server
145
+ // errors arriving via `fieldErrors` should also focus the first
146
+ // invalid + announce, mirroring the controller behavior.
147
+ watch(
148
+ () => props.statusMessage,
149
+ (msg) => {
150
+ if (msg) announce(msg, 'polite');
151
+ },
152
+ );
153
+
154
+ watch(
155
+ () => props.fieldErrors,
156
+ (newServerErrors) => {
157
+ const normalized = normalizeFieldErrors(newServerErrors);
158
+ const count = Object.keys(normalized).length;
159
+ if (count === 0) return;
160
+ requestAnimationFrame(() => {
161
+ const formEl = formRef.value;
162
+ if (!formEl) return;
163
+ focusFirstInvalid(formEl);
164
+ const message =
165
+ count === 1
166
+ ? '1 error found. Please review the form.'
167
+ : `${count} errors found. Please review the form.`;
168
+ announce(message, 'assertive');
169
+ });
170
+ },
171
+ );
172
+
173
+ defineExpose({
174
+ /** Field error map (client + server) — read-only view for consumers. */
175
+ fieldErrors: allFieldErrors,
176
+ });
177
+ </script>
178
+
179
+ <style>
180
+ @import './form.css';
181
+ </style>
@@ -0,0 +1,46 @@
1
+ @layer components {
2
+ .pui-form {
3
+ display: flex;
4
+ flex-direction: column;
5
+ gap: var(--space-m);
6
+ }
7
+
8
+ .pui-form__error-summary {
9
+ background: var(--bg-clr-danger-soft);
10
+ border: var(--stroke-sm) solid var(--border-clr-danger);
11
+ border-radius: var(--radius-sm);
12
+ padding: var(--space-s) var(--space-m);
13
+ }
14
+
15
+ .pui-form__error-summary-title {
16
+ font-weight: var(--fw-semibold);
17
+ color: var(--text-clr-danger);
18
+ margin-bottom: var(--space-xs);
19
+ }
20
+
21
+ .pui-form__error-summary-list {
22
+ list-style: disc inside;
23
+ padding: 0;
24
+ margin: 0;
25
+ color: var(--text-clr-danger);
26
+ font-size: var(--step--1);
27
+ }
28
+
29
+ .pui-form__error-summary-list li {
30
+ margin-block: var(--space-2xs);
31
+ }
32
+
33
+ .pui-form__status {
34
+ padding: var(--space-s) var(--space-m);
35
+ border-radius: var(--radius-sm);
36
+ background: var(--bg-clr-success-soft);
37
+ color: var(--text-clr-success);
38
+ font-weight: var(--fw-semibold);
39
+ }
40
+
41
+ .pui-form__actions {
42
+ display: flex;
43
+ gap: var(--space-s);
44
+ align-items: center;
45
+ }
46
+ }