@lumiose/ui 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// Field (decision-62, TASK-365): a label, a control, a description and its errors, wired together
|
|
2
|
+
// after Base UI's Field. The control is native (an `<input>`, a checkbox, a `<select>`), so
|
|
3
|
+
// constraint validation, `:invalid` and blocking a submission are the browser's; Field.Root
|
|
4
|
+
// renders `<lu-field>`, and field.script.ts (decision-63) shows the browser's verdict in the
|
|
5
|
+
// field's Error parts at the right time instead of the browser's bubble. Server errors, from a
|
|
6
|
+
// Form's `errors`, render in the HTML: they work without JavaScript. A `validate` function (an
|
|
7
|
+
// island) sets the control's custom validity; when it is async, the latest edit's verdict wins and
|
|
8
|
+
// a `validitychange` event on the control asks the script to show it.
|
|
9
|
+
import {
|
|
10
|
+
effect,
|
|
11
|
+
isServer,
|
|
12
|
+
onCleanup,
|
|
13
|
+
onMount,
|
|
14
|
+
ref,
|
|
15
|
+
signal,
|
|
16
|
+
untrack,
|
|
17
|
+
useContext,
|
|
18
|
+
useId,
|
|
19
|
+
} from "lumiose";
|
|
20
|
+
import {
|
|
21
|
+
controlId,
|
|
22
|
+
errorId,
|
|
23
|
+
FieldContext,
|
|
24
|
+
nextIndex,
|
|
25
|
+
numbered,
|
|
26
|
+
FormContext,
|
|
27
|
+
type FieldContextValue,
|
|
28
|
+
type FieldValidityState,
|
|
29
|
+
type ValidationMode,
|
|
30
|
+
} from "./context.ts";
|
|
31
|
+
import fieldScript from "./field.script.ts?client";
|
|
32
|
+
import { listenToForm, reportValidity } from "../form/native.ts";
|
|
33
|
+
import { Input, type InputProps } from "../input/input.tsx";
|
|
34
|
+
|
|
35
|
+
declare module "lumiose/jsx-runtime" {
|
|
36
|
+
namespace JSX {
|
|
37
|
+
interface IntrinsicElements {
|
|
38
|
+
/**
|
|
39
|
+
* Field's element, rendered by `Field.Root`. Attribute `validation-mode`: when the field
|
|
40
|
+
* shows its errors (`onSubmit`, `onBlur`, `onChange`).
|
|
41
|
+
*/
|
|
42
|
+
"lu-field": JSX.HTMLProps<HTMLElement> & { "validation-mode"?: ValidationMode };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A validity check of the field's own: the message when the value is invalid, else nothing, or a
|
|
49
|
+
* promise of it (a server check). Only the latest edit's result applies.
|
|
50
|
+
*/
|
|
51
|
+
export type FieldValidate = (
|
|
52
|
+
value: string | boolean,
|
|
53
|
+
) => string | null | undefined | Promise<string | null | undefined>;
|
|
54
|
+
|
|
55
|
+
export type { FieldValidityState };
|
|
56
|
+
|
|
57
|
+
/** The `ValidityState` keys a `Field.Error` can match. */
|
|
58
|
+
export type FieldErrorMatch = Exclude<keyof ValidityState, "valid">;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `Field.Root` renders `<lu-field>`, a block around the label, the control, the description and
|
|
62
|
+
* the errors.
|
|
63
|
+
*/
|
|
64
|
+
export type FieldRootProps = JSX.HTMLProps<HTMLElement> & {
|
|
65
|
+
/**
|
|
66
|
+
* The control's `name` (unless it has its own), and the key of the field's server errors in the
|
|
67
|
+
* Form's `errors`.
|
|
68
|
+
*/
|
|
69
|
+
name?: string;
|
|
70
|
+
/** The control is disabled. */
|
|
71
|
+
disabled?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Marks the field invalid, as a server error does: its Error shows and the control gets
|
|
74
|
+
* `aria-invalid`, until the value changes.
|
|
75
|
+
*/
|
|
76
|
+
invalid?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* When the field shows its errors: after a submit attempt (`"onSubmit"`, the default, or the
|
|
79
|
+
* Form's), also when the control loses focus (`"onBlur"`), or as soon as it is edited
|
|
80
|
+
* (`"onChange"`). Once shown, errors follow the value as you type.
|
|
81
|
+
*/
|
|
82
|
+
validationMode?: ValidationMode;
|
|
83
|
+
/**
|
|
84
|
+
* A check of your own, run on every edit in the browser (an island): return the message when
|
|
85
|
+
* the value is invalid. It becomes the control's custom validity, so a submit is blocked and the
|
|
86
|
+
* Error shows the message. The value is the control's, or `checked` for a checkbox or switch.
|
|
87
|
+
* An async check clears the message until it settles (`data-validating` on the field meanwhile)
|
|
88
|
+
* and does not block a submission that comes first; a newer edit discards an older result.
|
|
89
|
+
*/
|
|
90
|
+
validate?: FieldValidate;
|
|
91
|
+
/**
|
|
92
|
+
* How long typing must pause before `validate` runs, in ms: fewer server checks while the
|
|
93
|
+
* user types. Default `0`.
|
|
94
|
+
*/
|
|
95
|
+
validationDebounceTime?: number;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The `<label>` of the field's control. */
|
|
99
|
+
export type FieldLabelProps = JSX.IntrinsicElements["label"];
|
|
100
|
+
|
|
101
|
+
/** The field's `<input>`: `Input`, with every native prop. */
|
|
102
|
+
export type FieldControlProps = InputProps;
|
|
103
|
+
|
|
104
|
+
/** A `<p>` that describes the control; the control's `aria-describedby` names it. */
|
|
105
|
+
export type FieldDescriptionProps = JSX.IntrinsicElements["p"];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Renders what `children` returns for the field's validity, again whenever it changes: an edit, a
|
|
109
|
+
* blur, a submit attempt, a `validate` result or a server error. Before the first check (and on
|
|
110
|
+
* the server) `validity.valid` is `null`.
|
|
111
|
+
*/
|
|
112
|
+
export type FieldValidityProps = {
|
|
113
|
+
/** Renders from the field's validity: text, or elements inside an element of yours. */
|
|
114
|
+
children: (state: FieldValidityState) => JSX.Element;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* An `<output>` with an error message of the field. Without children it shows the field's
|
|
119
|
+
* message (`<output for>` the control): a server error, or the browser's (or `validate`'s) message
|
|
120
|
+
* for the problem. With children it shows them.
|
|
121
|
+
*/
|
|
122
|
+
export type FieldErrorProps = JSX.HTMLProps<HTMLElement> & {
|
|
123
|
+
/**
|
|
124
|
+
* When it shows: for any problem (left out), only for one `ValidityState` problem
|
|
125
|
+
* (`"valueMissing"`, `"typeMismatch"`, …), or always (`true`).
|
|
126
|
+
*/
|
|
127
|
+
match?: FieldErrorMatch | true;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** The `ValidityState` keys, `valid` included. */
|
|
131
|
+
const KEYS = [
|
|
132
|
+
"badInput",
|
|
133
|
+
"customError",
|
|
134
|
+
"patternMismatch",
|
|
135
|
+
"rangeOverflow",
|
|
136
|
+
"rangeUnderflow",
|
|
137
|
+
"stepMismatch",
|
|
138
|
+
"tooLong",
|
|
139
|
+
"tooShort",
|
|
140
|
+
"typeMismatch",
|
|
141
|
+
"valueMissing",
|
|
142
|
+
"valid",
|
|
143
|
+
] as const;
|
|
144
|
+
|
|
145
|
+
const VALID: FieldValidityState["validity"] = {
|
|
146
|
+
...(Object.fromEntries(KEYS.map((key) => [key, false])) as FieldValidityState["validity"]),
|
|
147
|
+
valid: true,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const UNCHECKED: FieldValidityState = {
|
|
151
|
+
validity: { ...VALID, valid: null },
|
|
152
|
+
error: "",
|
|
153
|
+
errors: [],
|
|
154
|
+
value: null,
|
|
155
|
+
validating: false,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const toggles = (control: Partial<HTMLInputElement>) =>
|
|
159
|
+
control.type === "checkbox" || control.type === "radio";
|
|
160
|
+
|
|
161
|
+
const fieldOf = (node: Element) => node.closest?.("lu-field") ?? null;
|
|
162
|
+
|
|
163
|
+
const isPromise = <T,>(value: T | Promise<T>): value is Promise<T> =>
|
|
164
|
+
typeof (value as { then?: unknown } | null)?.then === "function";
|
|
165
|
+
|
|
166
|
+
function Root({
|
|
167
|
+
name,
|
|
168
|
+
disabled,
|
|
169
|
+
invalid,
|
|
170
|
+
validationMode,
|
|
171
|
+
validate,
|
|
172
|
+
validationDebounceTime,
|
|
173
|
+
children,
|
|
174
|
+
...rest
|
|
175
|
+
}: FieldRootProps) {
|
|
176
|
+
// Shows the browser's validation in the Error parts; the page loads it once (decision-63).
|
|
177
|
+
fieldScript();
|
|
178
|
+
const id = useId();
|
|
179
|
+
const form = useContext(FormContext);
|
|
180
|
+
const messages = () => {
|
|
181
|
+
const errors = name ? form?.errors() : undefined;
|
|
182
|
+
const value = errors ? (errors as Record<string, unknown>)[name!] : undefined;
|
|
183
|
+
if (typeof value === "string") return value ? [value] : [];
|
|
184
|
+
return Array.isArray(value) ? value.filter((m): m is string => typeof m === "string") : [];
|
|
185
|
+
};
|
|
186
|
+
/** The last check of the control, and the Form's errors it saw. */
|
|
187
|
+
const checked = signal<{ state: FieldValidityState; errors: unknown } | undefined>(undefined);
|
|
188
|
+
const validity = (): FieldValidityState => {
|
|
189
|
+
const last = checked();
|
|
190
|
+
const server = messages();
|
|
191
|
+
// A server error newer than the last check is the verdict, until the next edit.
|
|
192
|
+
if (server.length > 0 && (!last || last.errors !== form?.errors())) {
|
|
193
|
+
return {
|
|
194
|
+
validity: { ...VALID, valid: false },
|
|
195
|
+
error: server.join(" "),
|
|
196
|
+
errors: server,
|
|
197
|
+
value: last?.state.value ?? null,
|
|
198
|
+
validating: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return last?.state ?? UNCHECKED;
|
|
202
|
+
};
|
|
203
|
+
const field: FieldContextValue = {
|
|
204
|
+
id,
|
|
205
|
+
name: () => name,
|
|
206
|
+
disabled: () => disabled === true,
|
|
207
|
+
invalid: () => invalid === true || messages().length > 0,
|
|
208
|
+
messages,
|
|
209
|
+
descriptions: signal(0),
|
|
210
|
+
rendered: new Map(),
|
|
211
|
+
labels: signal(0),
|
|
212
|
+
validity,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const host = ref<HTMLElement>();
|
|
216
|
+
const snapshot = (target: EventTarget | null) => {
|
|
217
|
+
const control = target as Partial<HTMLInputElement> | null;
|
|
218
|
+
if (!control?.validity || fieldOf(control as Element) !== host()) return;
|
|
219
|
+
const state = control.validity;
|
|
220
|
+
const error = control.validationMessage ?? "";
|
|
221
|
+
checked.set({
|
|
222
|
+
state: {
|
|
223
|
+
validity: Object.fromEntries(
|
|
224
|
+
KEYS.map((key) => [key, state[key]]),
|
|
225
|
+
) as FieldValidityState["validity"],
|
|
226
|
+
error,
|
|
227
|
+
errors: error ? [error] : [],
|
|
228
|
+
value: toggles(control) ? control.checked! : (control.value ?? null),
|
|
229
|
+
validating: host()?.hasAttribute("data-validating") === true,
|
|
230
|
+
},
|
|
231
|
+
errors: untrack(() => form?.errors()),
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// `validate` sets the control's custom validity on every edit, and quietly (no check for
|
|
236
|
+
// `Field.Validity`) at the start and after a reset. A newer run makes an older one's pending
|
|
237
|
+
// result stale.
|
|
238
|
+
let latest = 0;
|
|
239
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
240
|
+
/** A debounced check that has not run yet: a submit attempt runs it first (TASK-459). */
|
|
241
|
+
let due: (() => void) | undefined;
|
|
242
|
+
/** The value of the last async check, whose verdict (pending or not) stands for that value. */
|
|
243
|
+
let checking: { value: string | boolean } | undefined;
|
|
244
|
+
onCleanup(() => clearTimeout(timer));
|
|
245
|
+
const pending = (on: boolean) => host()?.toggleAttribute("data-validating", on);
|
|
246
|
+
const run = (target: EventTarget | null, wait = 0, quiet = false) => {
|
|
247
|
+
if (!validate || !(target instanceof HTMLElement)) return;
|
|
248
|
+
const control = target as HTMLInputElement;
|
|
249
|
+
if (typeof control.setCustomValidity !== "function") return;
|
|
250
|
+
const value = toggles(control) ? control.checked : control.value;
|
|
251
|
+
// The blur's `change` after an edit: that value's server check stands, no second request.
|
|
252
|
+
if (checking?.value === value) return;
|
|
253
|
+
const mine = ++latest;
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
due = undefined;
|
|
256
|
+
const apply = (message: string | null | undefined, later: boolean) => {
|
|
257
|
+
if (mine !== latest || !control.isConnected) return;
|
|
258
|
+
control.setCustomValidity(message ?? "");
|
|
259
|
+
pending(false);
|
|
260
|
+
// The script showed the edit already: a later verdict asks it to show this one.
|
|
261
|
+
if (later) control.dispatchEvent(new Event("validitychange"));
|
|
262
|
+
if (!quiet) snapshot(control);
|
|
263
|
+
};
|
|
264
|
+
const check = (later: boolean) => {
|
|
265
|
+
const result = untrack(() => validate(value));
|
|
266
|
+
checking = isPromise(result) ? { value } : undefined;
|
|
267
|
+
if (!isPromise(result)) return apply(result, later);
|
|
268
|
+
// The last verdict was about another value: none until this one settles.
|
|
269
|
+
control.setCustomValidity("");
|
|
270
|
+
pending(true);
|
|
271
|
+
// A check that fails (the network) blocks nothing: the server validates again.
|
|
272
|
+
result.then(
|
|
273
|
+
(message) => apply(message, true),
|
|
274
|
+
() => apply(null, true),
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
if (wait <= 0) return check(false);
|
|
278
|
+
control.setCustomValidity("");
|
|
279
|
+
pending(true);
|
|
280
|
+
due = () => {
|
|
281
|
+
due = undefined;
|
|
282
|
+
check(true);
|
|
283
|
+
};
|
|
284
|
+
timer = setTimeout(due, wait);
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* A submission inside the debounce window would pass the browser's checks without `validate`
|
|
288
|
+
* (cleared while it waits): its check runs now, and a synchronous one that fails stops the
|
|
289
|
+
* submission, which the form then reports. An async check still waits for the server.
|
|
290
|
+
*/
|
|
291
|
+
const onSubmit = (event: Event) => {
|
|
292
|
+
if (!due) return;
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
due();
|
|
295
|
+
const control = document.getElementById(controlId(field)) as HTMLInputElement | null;
|
|
296
|
+
if (!control || control.validity.valid) return;
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
event.stopImmediatePropagation();
|
|
299
|
+
reportValidity(event.currentTarget as HTMLFormElement);
|
|
300
|
+
};
|
|
301
|
+
effect(() => {
|
|
302
|
+
if (!validate) return;
|
|
303
|
+
const element = document.getElementById(controlId(field));
|
|
304
|
+
if (element) untrack(() => run(element, 0, true));
|
|
305
|
+
});
|
|
306
|
+
// A form action's errors for this field, each time they arrive: the script shows them again
|
|
307
|
+
// even when they repeat, which no binding would apply again once an edit hid them (TASK-429).
|
|
308
|
+
effect(() => {
|
|
309
|
+
const errors = form?.errors();
|
|
310
|
+
const list = messages();
|
|
311
|
+
const element = untrack(host);
|
|
312
|
+
if (errors && list.length > 0 && element) {
|
|
313
|
+
element.dispatchEvent(new CustomEvent("validitychange", { detail: { errors: list } }));
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
// A submit attempt checks every control (`invalid` does not bubble); a reset clears the checks.
|
|
317
|
+
const onInvalid = (event: Event) => snapshot(event.target);
|
|
318
|
+
const onReset = () => {
|
|
319
|
+
checked.set(undefined);
|
|
320
|
+
checking = undefined;
|
|
321
|
+
// The controls take their default values after the event.
|
|
322
|
+
setTimeout(() => run(document.getElementById(controlId(field)), 0, true));
|
|
323
|
+
};
|
|
324
|
+
onMount(() => {
|
|
325
|
+
const element = host();
|
|
326
|
+
if (!element) return;
|
|
327
|
+
const owner = element.closest("form");
|
|
328
|
+
element.addEventListener("invalid", onInvalid, true);
|
|
329
|
+
const stops = owner
|
|
330
|
+
? [
|
|
331
|
+
listenToForm(owner, "reset", onReset),
|
|
332
|
+
// Capturing: before the form's own `submit` listeners.
|
|
333
|
+
listenToForm(owner, "submit", onSubmit, true),
|
|
334
|
+
]
|
|
335
|
+
: [];
|
|
336
|
+
onCleanup(() => {
|
|
337
|
+
element.removeEventListener("invalid", onInvalid, true);
|
|
338
|
+
for (const stop of stops) stop();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<FieldContext value={field}>
|
|
344
|
+
<lu-field
|
|
345
|
+
{...rest}
|
|
346
|
+
ref={(element: HTMLElement) => {
|
|
347
|
+
host(element);
|
|
348
|
+
rest.ref?.(element);
|
|
349
|
+
}}
|
|
350
|
+
id={id}
|
|
351
|
+
validation-mode={validationMode ?? form?.validationMode()}
|
|
352
|
+
on:input={(event: Event) => {
|
|
353
|
+
run(event.target, validationDebounceTime);
|
|
354
|
+
snapshot(event.target);
|
|
355
|
+
}}
|
|
356
|
+
on:change={(event: Event) => {
|
|
357
|
+
run(event.target);
|
|
358
|
+
snapshot(event.target);
|
|
359
|
+
}}
|
|
360
|
+
on:focusout={(event: Event) => snapshot(event.target)}
|
|
361
|
+
>
|
|
362
|
+
{children}
|
|
363
|
+
</lu-field>
|
|
364
|
+
</FieldContext>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function Validity({ children }: FieldValidityProps) {
|
|
369
|
+
const field = useFieldPart("Validity");
|
|
370
|
+
return <>{children(field.validity())}</>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function useFieldPart(part: string): FieldContextValue {
|
|
374
|
+
const field = useContext(FieldContext);
|
|
375
|
+
if (!field) throw new Error(`Field.${part} must be inside <Field.Root>.`);
|
|
376
|
+
return field;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function Label({ children, ...rest }: FieldLabelProps) {
|
|
380
|
+
const field = useFieldPart("Label");
|
|
381
|
+
// Counted as Description is, for the group controls it names (TASK-439).
|
|
382
|
+
let counted = false;
|
|
383
|
+
const register = () => {
|
|
384
|
+
counted = true;
|
|
385
|
+
field.labels.update((n) => n + 1);
|
|
386
|
+
};
|
|
387
|
+
onCleanup(() => {
|
|
388
|
+
if (counted) field.labels.update((n) => n - 1);
|
|
389
|
+
});
|
|
390
|
+
if (isServer) register();
|
|
391
|
+
else onMount(register);
|
|
392
|
+
return (
|
|
393
|
+
<label {...rest} id={`${field.id}-label`} for={controlId(field)}>
|
|
394
|
+
{children}
|
|
395
|
+
</label>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function Description({ children, ...rest }: FieldDescriptionProps) {
|
|
400
|
+
const field = useFieldPart("Description");
|
|
401
|
+
// The server counts it as it renders (a control after it sees it); the browser once mounted,
|
|
402
|
+
// so hydrating the control before it does not change under its feet.
|
|
403
|
+
let counted = false;
|
|
404
|
+
const register = () => {
|
|
405
|
+
counted = true;
|
|
406
|
+
field.descriptions.update((n) => n + 1);
|
|
407
|
+
};
|
|
408
|
+
onCleanup(() => {
|
|
409
|
+
if (counted) field.descriptions.update((n) => n - 1);
|
|
410
|
+
});
|
|
411
|
+
if (isServer) register();
|
|
412
|
+
else onMount(register);
|
|
413
|
+
return (
|
|
414
|
+
<p {...rest} id={numbered(`${field.id}-description`, nextIndex(field, "description"))}>
|
|
415
|
+
{children}
|
|
416
|
+
</p>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function ErrorPart({ match, children, ...rest }: FieldErrorProps) {
|
|
421
|
+
const field = useFieldPart("Error");
|
|
422
|
+
// The script shows and hides it from then on. Without children it is the field's message, an
|
|
423
|
+
// `<output for>` the control: the server's error, or the browser's message, which the script
|
|
424
|
+
// writes into the same text node a new server error updates.
|
|
425
|
+
const visible = () => match === true || (match === undefined && field.invalid());
|
|
426
|
+
const message = () => children === undefined;
|
|
427
|
+
/**
|
|
428
|
+
* `<id>-error`, `<id>-error-invalid`, `<id>-error-always`, `<id>-error-<key>`; a second Error
|
|
429
|
+
* of the same kind adds `-2` (TASK-446, L14).
|
|
430
|
+
*/
|
|
431
|
+
const kind = `${errorId(field)}${
|
|
432
|
+
match === undefined ? (message() ? "" : "-invalid") : match === true ? "-always" : `-${match}`
|
|
433
|
+
}`;
|
|
434
|
+
const index = nextIndex(field, kind);
|
|
435
|
+
const id = () => numbered(kind, index);
|
|
436
|
+
return (
|
|
437
|
+
// Not a live region: the control's description says it, without re-announcing each keystroke
|
|
438
|
+
// (TASK-439, M31).
|
|
439
|
+
<output
|
|
440
|
+
aria-live="off"
|
|
441
|
+
{...rest}
|
|
442
|
+
id={id()}
|
|
443
|
+
for={message() ? controlId(field) : undefined}
|
|
444
|
+
hidden={!visible()}
|
|
445
|
+
>
|
|
446
|
+
{message() ? field.messages().join(" ") : children}
|
|
447
|
+
</output>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* A form field: a label, a control (`Field.Control`, or Checkbox, Switch, Select), a description
|
|
453
|
+
* and its errors, wired with `for`, `aria-describedby` and `aria-invalid`. Validation is the
|
|
454
|
+
* browser's constraint validation (`required`, `type`, `pattern`, …), shown in the field instead
|
|
455
|
+
* of the browser's bubble where JavaScript runs; server errors come from a `Form`'s `errors`.
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* <Field.Root name="email">
|
|
459
|
+
* <Field.Label>Email</Field.Label>
|
|
460
|
+
* <Field.Control type="email" required />
|
|
461
|
+
* <Field.Description>We never share it.</Field.Description>
|
|
462
|
+
* <Field.Error />
|
|
463
|
+
* </Field.Root>
|
|
464
|
+
*/
|
|
465
|
+
export const Field = { Root, Label, Control: Input, Description, Error: ErrorPart, Validity };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Fieldset (decision-62, TASK-365): a native `<fieldset>` and `<legend>`, after Base UI's
|
|
2
|
+
// Fieldset. The platform groups the controls, names the group by its legend and disables every
|
|
3
|
+
// control inside a disabled fieldset, in server HTML and without JavaScript; no script, no CSS.
|
|
4
|
+
|
|
5
|
+
import { untrack, useContext } from "lumiose";
|
|
6
|
+
import { FormContext } from "../field/context.ts";
|
|
7
|
+
|
|
8
|
+
/** `Fieldset.Root` renders a `<fieldset>`; `disabled` disables every control inside it. */
|
|
9
|
+
export type FieldsetRootProps = JSX.IntrinsicElements["fieldset"];
|
|
10
|
+
|
|
11
|
+
/** The `<legend>` that names the fieldset; it must be the fieldset's first child. */
|
|
12
|
+
export type FieldsetLegendProps = JSX.IntrinsicElements["legend"];
|
|
13
|
+
|
|
14
|
+
function Root({ children, ...rest }: FieldsetRootProps) {
|
|
15
|
+
const form = useContext(FormContext);
|
|
16
|
+
// Its controls submit nothing while it is disabled: a Form's `values` do not fill them.
|
|
17
|
+
const context =
|
|
18
|
+
form && untrack(() => rest.disabled) ? { ...form, values: () => undefined } : form;
|
|
19
|
+
return (
|
|
20
|
+
<FormContext value={context}>
|
|
21
|
+
<fieldset {...rest}>{children}</fieldset>
|
|
22
|
+
</FormContext>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Legend({ children, ...rest }: FieldsetLegendProps) {
|
|
27
|
+
return <legend {...rest}>{children}</legend>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A group of related fields with a caption: a native `<fieldset>` and `<legend>`. Also the label
|
|
32
|
+
* of a `CheckboxGroup` or a `RadioGroup` (which render a fieldset themselves): put a
|
|
33
|
+
* `Fieldset.Legend` first inside them.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* <Fieldset.Root>
|
|
37
|
+
* <Fieldset.Legend>Shipping address</Fieldset.Legend>
|
|
38
|
+
* …fields…
|
|
39
|
+
* </Fieldset.Root>
|
|
40
|
+
*/
|
|
41
|
+
export const Fieldset = { Root, Legend };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Form (decision-62, TASK-365): a native `<form>` that hands its fields the server's errors, after
|
|
2
|
+
// Base UI's Form. Submission is the platform's (or Lumiose's `submit()` in `onSubmit`): a form
|
|
3
|
+
// action's `fail(status, errors)` data, passed as `errors`, shows in each field's Error by the
|
|
4
|
+
// field's name, in server HTML and in islands. Field's client script shows the browser's
|
|
5
|
+
// constraint validation in the fields and focuses the first invalid one on a submit attempt.
|
|
6
|
+
import {
|
|
7
|
+
FormContext,
|
|
8
|
+
type FormErrors,
|
|
9
|
+
type FormValues,
|
|
10
|
+
type ValidationMode,
|
|
11
|
+
} from "../field/context.ts";
|
|
12
|
+
|
|
13
|
+
export type { FormValues };
|
|
14
|
+
|
|
15
|
+
/** A native `<form>`, with every native prop (`method`, `action`, `onSubmit`, …). */
|
|
16
|
+
export type FormProps = JSX.IntrinsicElements["form"] & {
|
|
17
|
+
/**
|
|
18
|
+
* The server's errors by field name: a message or several for each `Field.Root` `name`, such as
|
|
19
|
+
* the data a form action passes to `fail()` (`actionData()`). A field's error goes away when its
|
|
20
|
+
* value changes.
|
|
21
|
+
*/
|
|
22
|
+
errors?: FormErrors | null;
|
|
23
|
+
/** When the fields show their errors, unless a field sets its own. Default `"onSubmit"`. */
|
|
24
|
+
validationMode?: ValidationMode;
|
|
25
|
+
/**
|
|
26
|
+
* The values to fill the controls with, by name, such as the `FormData` a form action passes back
|
|
27
|
+
* with `fail()` after a failed submission, so the user does not type everything again. They
|
|
28
|
+
* replace the controls' defaults; controls with a `value` (or `bind:`) keep theirs, and
|
|
29
|
+
* passwords and files are never filled.
|
|
30
|
+
*/
|
|
31
|
+
values?: FormValues | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A native form whose fields show the server's errors (`errors`) and the browser's validation.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* <Form method="post" action="?/signup" onSubmit={submit} errors={actionData<typeof actions>()}>
|
|
39
|
+
* <Field.Root name="email">…</Field.Root>
|
|
40
|
+
* </Form>
|
|
41
|
+
*/
|
|
42
|
+
export function Form({ errors, validationMode, values, children, ...rest }: FormProps) {
|
|
43
|
+
return (
|
|
44
|
+
<FormContext
|
|
45
|
+
value={{ errors: () => errors, validationMode: () => validationMode, values: () => values }}
|
|
46
|
+
>
|
|
47
|
+
<form {...rest}>{children}</form>
|
|
48
|
+
</FormContext>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// A form's own members, read through the prototypes (TASK-473, L9). A form element exposes its
|
|
2
|
+
// controls by name, ahead of its own properties: a control named `reportValidity` or
|
|
3
|
+
// `addEventListener` shadows the form's member of that name (DOM clobbering), and calling it
|
|
4
|
+
// throws. For island code; each client script keeps its own copy of what it needs (a shared
|
|
5
|
+
// module would be a chunk of its own).
|
|
6
|
+
|
|
7
|
+
/** Checks the form's controls and reports the problems, as a submit attempt does. */
|
|
8
|
+
export function reportValidity(form: HTMLFormElement): boolean {
|
|
9
|
+
return HTMLFormElement.prototype.reportValidity.call(form);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Listens to `type` on the form; returns the function that stops listening. */
|
|
13
|
+
export function listenToForm(
|
|
14
|
+
form: HTMLFormElement,
|
|
15
|
+
type: string,
|
|
16
|
+
listener: EventListener,
|
|
17
|
+
capture = false,
|
|
18
|
+
): () => void {
|
|
19
|
+
EventTarget.prototype.addEventListener.call(form, type, listener, capture);
|
|
20
|
+
return () => EventTarget.prototype.removeEventListener.call(form, type, listener, capture);
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Form resets in islands (TASK-435). A native reset puts the controls back to their defaults
|
|
2
|
+
// without an `input` or `change` event, so a component's `onValueChange` / `onCheckedChange`, and
|
|
3
|
+
// a `bind:` signal through them, would keep the edited value. `onFormReset` reads the control's
|
|
4
|
+
// value when its form resets and again a task later, once the controls (and the elements'
|
|
5
|
+
// scripts, which follow the reset the same way) hold their defaults, and reports the new value
|
|
6
|
+
// when it differs.
|
|
7
|
+
import { onCleanup, onMount } from "lumiose";
|
|
8
|
+
import { listenToForm } from "./native.ts";
|
|
9
|
+
|
|
10
|
+
const same = (a: unknown, b: unknown) =>
|
|
11
|
+
Array.isArray(a) && Array.isArray(b)
|
|
12
|
+
? a.length === b.length && a.every((item, i) => Object.is(item, b[i]))
|
|
13
|
+
: Object.is(a, b);
|
|
14
|
+
|
|
15
|
+
/** Calls `atReset` when `element`'s form resets, and what it returns a task later. */
|
|
16
|
+
function listen(element: () => Element | undefined, atReset: () => () => void): void {
|
|
17
|
+
onMount(() => {
|
|
18
|
+
const host = element();
|
|
19
|
+
const form =
|
|
20
|
+
(host as { form?: HTMLFormElement | null } | undefined)?.form ?? host?.closest("form");
|
|
21
|
+
if (!form) return;
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
23
|
+
const listener = () => {
|
|
24
|
+
const later = atReset();
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
timer = setTimeout(later);
|
|
27
|
+
};
|
|
28
|
+
const stop = listenToForm(form, "reset", listener);
|
|
29
|
+
onCleanup(() => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
stop();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function onFormReset<T>(
|
|
37
|
+
element: () => Element | undefined,
|
|
38
|
+
read: () => T,
|
|
39
|
+
report: (value: T) => void,
|
|
40
|
+
): void {
|
|
41
|
+
listen(element, () => {
|
|
42
|
+
const before = read();
|
|
43
|
+
return () => {
|
|
44
|
+
const after = read();
|
|
45
|
+
if (!same(before, after)) report(after);
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Runs `run` a task after `element`'s form resets, once the controls hold their defaults. */
|
|
51
|
+
export function afterFormReset(element: () => Element | undefined, run: () => void): void {
|
|
52
|
+
listen(element, () => run);
|
|
53
|
+
}
|