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