@lumiose/ui 0.0.0-stage → 0.1.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 (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/accordion/accordion.css +20 -0
  4. package/dist/accordion/accordion.script.ts +66 -0
  5. package/dist/accordion/accordion.tsx +288 -0
  6. package/dist/animation/animation.ts +34 -0
  7. package/dist/autocomplete/autocomplete.tsx +239 -0
  8. package/dist/avatar/avatar.css +54 -0
  9. package/dist/avatar/avatar.script.ts +96 -0
  10. package/dist/avatar/avatar.tsx +110 -0
  11. package/dist/button/button.script.ts +35 -0
  12. package/dist/button/button.tsx +40 -0
  13. package/dist/checkbox/checkbox.script.ts +225 -0
  14. package/dist/checkbox/checkbox.tsx +156 -0
  15. package/dist/checkbox/context.ts +17 -0
  16. package/dist/checkbox-group/checkbox-group.tsx +111 -0
  17. package/dist/collapsible/collapsible.css +12 -0
  18. package/dist/collapsible/collapsible.script.ts +7 -0
  19. package/dist/collapsible/collapsible.tsx +153 -0
  20. package/dist/collapsible/panel.ts +115 -0
  21. package/dist/combobox/combobox.css +37 -0
  22. package/dist/combobox/combobox.script.ts +974 -0
  23. package/dist/combobox/combobox.tsx +556 -0
  24. package/dist/combobox/context.ts +46 -0
  25. package/dist/context-menu/context-menu.css +16 -0
  26. package/dist/context-menu/context-menu.script.ts +172 -0
  27. package/dist/context-menu/context-menu.tsx +178 -0
  28. package/dist/dialog/dialog.css +35 -0
  29. package/dist/dialog/dialog.script.ts +269 -0
  30. package/dist/dialog/dialog.tsx +214 -0
  31. package/dist/drawer/drawer.css +38 -0
  32. package/dist/drawer/drawer.script.ts +207 -0
  33. package/dist/drawer/drawer.tsx +113 -0
  34. package/dist/field/context.ts +156 -0
  35. package/dist/field/field.css +16 -0
  36. package/dist/field/field.script.ts +422 -0
  37. package/dist/field/field.tsx +465 -0
  38. package/dist/fieldset/fieldset.tsx +41 -0
  39. package/dist/form/form.tsx +50 -0
  40. package/dist/form/native.ts +21 -0
  41. package/dist/form/reset.ts +53 -0
  42. package/dist/index.ts +261 -0
  43. package/dist/input/input.tsx +67 -0
  44. package/dist/layer/layer.ts +191 -0
  45. package/dist/locale/digits.ts +21 -0
  46. package/dist/menu/menu.css +22 -0
  47. package/dist/menu/menu.script.ts +591 -0
  48. package/dist/menu/menu.tsx +520 -0
  49. package/dist/menubar/menubar.css +16 -0
  50. package/dist/menubar/menubar.script.ts +195 -0
  51. package/dist/menubar/menubar.tsx +67 -0
  52. package/dist/meter/meter.tsx +134 -0
  53. package/dist/navigation-menu/navigation-menu.css +14 -0
  54. package/dist/navigation-menu/navigation-menu.script.ts +466 -0
  55. package/dist/navigation-menu/navigation-menu.tsx +266 -0
  56. package/dist/number-field/format.ts +70 -0
  57. package/dist/number-field/number-field.css +38 -0
  58. package/dist/number-field/number-field.script.ts +490 -0
  59. package/dist/number-field/number-field.tsx +403 -0
  60. package/dist/otp-field/otp-field.css +45 -0
  61. package/dist/otp-field/otp-field.script.ts +330 -0
  62. package/dist/otp-field/otp-field.tsx +320 -0
  63. package/dist/placement/place.ts +148 -0
  64. package/dist/placement/placement.css +155 -0
  65. package/dist/placement/placement.ts +60 -0
  66. package/dist/popover/hover.ts +178 -0
  67. package/dist/popover/popover.css +20 -0
  68. package/dist/popover/popover.script.ts +256 -0
  69. package/dist/popover/popover.tsx +317 -0
  70. package/dist/preview-card/preview-card.css +7 -0
  71. package/dist/preview-card/preview-card.script.ts +136 -0
  72. package/dist/preview-card/preview-card.tsx +128 -0
  73. package/dist/progress/progress.tsx +155 -0
  74. package/dist/progress/value.ts +23 -0
  75. package/dist/radio-group/radio-group.tsx +156 -0
  76. package/dist/scroll-area/scroll-area.css +72 -0
  77. package/dist/scroll-area/scroll-area.script.ts +281 -0
  78. package/dist/scroll-area/scroll-area.tsx +118 -0
  79. package/dist/select/select.css +25 -0
  80. package/dist/select/select.tsx +171 -0
  81. package/dist/separator/separator.css +12 -0
  82. package/dist/separator/separator.tsx +38 -0
  83. package/dist/slider/format.ts +33 -0
  84. package/dist/slider/slider.css +93 -0
  85. package/dist/slider/slider.script.ts +355 -0
  86. package/dist/slider/slider.tsx +374 -0
  87. package/dist/styles.css +30 -0
  88. package/dist/switch/switch.tsx +92 -0
  89. package/dist/tabs/tabs.css +54 -0
  90. package/dist/tabs/tabs.script.ts +225 -0
  91. package/dist/tabs/tabs.tsx +272 -0
  92. package/dist/themes/default.css +2111 -0
  93. package/dist/toast/command.ts +3 -0
  94. package/dist/toast/toast.css +68 -0
  95. package/dist/toast/toast.script.ts +516 -0
  96. package/dist/toast/toast.tsx +432 -0
  97. package/dist/toggle/context.ts +16 -0
  98. package/dist/toggle/toggle.tsx +99 -0
  99. package/dist/toggle-group/toggle-group.script.ts +166 -0
  100. package/dist/toggle-group/toggle-group.tsx +117 -0
  101. package/dist/toolbar/context.ts +18 -0
  102. package/dist/toolbar/toolbar.css +12 -0
  103. package/dist/toolbar/toolbar.script.ts +123 -0
  104. package/dist/toolbar/toolbar.tsx +180 -0
  105. package/dist/tooltip/tooltip.css +8 -0
  106. package/dist/tooltip/tooltip.script.ts +222 -0
  107. package/dist/tooltip/tooltip.tsx +149 -0
  108. package/docs/README.md +174 -0
  109. package/docs/accordion.md +208 -0
  110. package/docs/autocomplete.md +161 -0
  111. package/docs/avatar.md +198 -0
  112. package/docs/button.md +98 -0
  113. package/docs/checkbox-group.md +112 -0
  114. package/docs/checkbox.md +133 -0
  115. package/docs/collapsible.md +165 -0
  116. package/docs/combobox.md +369 -0
  117. package/docs/context-menu.md +197 -0
  118. package/docs/dialog.md +256 -0
  119. package/docs/drawer.md +204 -0
  120. package/docs/field.md +295 -0
  121. package/docs/fieldset.md +92 -0
  122. package/docs/form.md +131 -0
  123. package/docs/input.md +87 -0
  124. package/docs/installation.md +65 -0
  125. package/docs/llms-full.txt +6923 -0
  126. package/docs/llms.txt +53 -0
  127. package/docs/menu.md +365 -0
  128. package/docs/menubar.md +186 -0
  129. package/docs/meter.md +103 -0
  130. package/docs/navigation-menu.md +257 -0
  131. package/docs/number-field.md +181 -0
  132. package/docs/otp-field.md +160 -0
  133. package/docs/popover.md +271 -0
  134. package/docs/preview-card.md +130 -0
  135. package/docs/progress.md +115 -0
  136. package/docs/radio-group.md +127 -0
  137. package/docs/scroll-area.md +147 -0
  138. package/docs/select.md +236 -0
  139. package/docs/separator.md +116 -0
  140. package/docs/slider.md +184 -0
  141. package/docs/switch.md +98 -0
  142. package/docs/tabs.md +254 -0
  143. package/docs/theming.md +132 -0
  144. package/docs/toast.md +311 -0
  145. package/docs/toggle-group.md +119 -0
  146. package/docs/toggle.md +94 -0
  147. package/docs/toolbar.md +138 -0
  148. package/docs/tooltip.md +128 -0
  149. package/package.json +102 -3
@@ -0,0 +1,156 @@
1
+ // The context a Field gives the control inside it (Input, Checkbox, Switch, Radio, Select): the
2
+ // control takes the field's id, name, disabled state and ARIA references unless its own props set
3
+ // them. The Form's context gives the fields its server errors and default validation mode.
4
+ import { createContext, untrack, useContext, type Signal } from "lumiose";
5
+
6
+ /** When a field shows its validation errors (Base UI's modes). */
7
+ export type ValidationMode = "onSubmit" | "onBlur" | "onChange";
8
+
9
+ /** Server errors by field name: a message or several. */
10
+ export type FormErrors = object;
11
+
12
+ /**
13
+ * The values a form action sends back after a failed submission (TASK-430): the submitted
14
+ * `FormData`, or values by field name.
15
+ */
16
+ export type FormValues =
17
+ | FormData
18
+ | URLSearchParams
19
+ | Readonly<Record<string, string | readonly string[] | null | undefined>>;
20
+
21
+ export interface FormContextValue {
22
+ errors: () => FormErrors | null | undefined;
23
+ validationMode: () => ValidationMode | undefined;
24
+ values: () => FormValues | null | undefined;
25
+ }
26
+
27
+ export const FormContext = createContext<FormContextValue>();
28
+
29
+ /**
30
+ * What the Form's `values` hold for a control's `name`, read once as the control's default: every
31
+ * value under the name (`[]` when the submission left it out, as for an unchecked checkbox), or
32
+ * `undefined` without `values` or a name, and for a disabled control, which submits nothing
33
+ * (`disabled` here, a Field's, or a disabled `Fieldset.Root`'s).
34
+ */
35
+ export function useFormValues(name: string | undefined, disabled?: unknown): string[] | undefined {
36
+ const values = untrack(() => useContext(FormContext)?.values());
37
+ if (values == null || !name || disabled || untrack(() => useField()?.disabled())) {
38
+ return undefined;
39
+ }
40
+ if (values instanceof FormData || values instanceof URLSearchParams) {
41
+ return values.getAll(name).filter((value): value is string => typeof value === "string");
42
+ }
43
+ const value = (values as Record<string, unknown>)[name];
44
+ if (typeof value === "string") return [value];
45
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
46
+ }
47
+
48
+ /** The field's validity, for `Field.Validity`: the control's, from its last check. */
49
+ export interface FieldValidityState {
50
+ /**
51
+ * The control's `ValidityState`, with `valid` `null` until the field is first checked (an edit,
52
+ * a blur or a submit attempt), or `false` from a server error.
53
+ */
54
+ validity: { [K in keyof ValidityState]: K extends "valid" ? boolean | null : boolean };
55
+ /** The first problem's message (the browser's, `validate`'s or the server's); `""` when none. */
56
+ error: string;
57
+ /** Every message: the server's, or the one above. */
58
+ errors: string[];
59
+ /** The control's value, `checked` for a checkbox or switch; `null` until the first check. */
60
+ value: string | boolean | null;
61
+ /** An async `validate` is running. */
62
+ validating: boolean;
63
+ }
64
+
65
+ export interface FieldContextValue {
66
+ /** The `lu-field`'s id; the parts' ids derive from it. */
67
+ id: string;
68
+ name: () => string | undefined;
69
+ disabled: () => boolean;
70
+ /** Invalid from the server (the Form's errors) or the `invalid` prop. */
71
+ invalid: () => boolean;
72
+ /** The Form's error messages for the field's name. */
73
+ messages: () => string[];
74
+ /** How many Description parts are rendered. */
75
+ descriptions: Signal<number>;
76
+ /**
77
+ * Parts rendered so far, by kind (`description`, or an Error's match): the second and later of a
78
+ * kind get an id of their own, `-2`, `-3`… (TASK-446, L14).
79
+ */
80
+ rendered: Map<string, number>;
81
+ /** How many Label parts are rendered: a group control is named by the label (TASK-439). */
82
+ labels: Signal<number>;
83
+ /** The control's validity from its last check, in the browser (`Field.Validity`). */
84
+ validity: () => FieldValidityState;
85
+ }
86
+
87
+ export const FieldContext = createContext<FieldContextValue>();
88
+
89
+ /** The id of the field's control, which its Label points at. */
90
+ export const controlId = (field: FieldContextValue) => `${field.id}-control`;
91
+
92
+ /** The field's error slot: the `Field.Error` without `match`, which shows the field's message. */
93
+ export const errorId = (field: FieldContextValue) => `${field.id}-error`;
94
+
95
+ /**
96
+ * The ids that describe the field's control: its descriptions and, while it is invalid from the
97
+ * server or the `invalid` prop, its error. The client script adds the errors it shows.
98
+ */
99
+ export function describedBy(field: FieldContextValue): string | undefined {
100
+ const ids = [
101
+ ...Array.from({ length: field.descriptions() }, (_, i) =>
102
+ numbered(`${field.id}-description`, i),
103
+ ),
104
+ field.invalid() ? errorId(field) : "",
105
+ ].filter(Boolean);
106
+ return ids.length > 0 ? ids.join(" ") : undefined;
107
+ }
108
+
109
+ /**
110
+ * The id that names a group control (a Radio Group or Checkbox Group) in a field: the Label's, as
111
+ * a group has no single element for the Label's `for` (TASK-439). `undefined` without a Label.
112
+ */
113
+ export const labelledBy = (field: FieldContextValue | undefined) =>
114
+ field && field.labels() > 0 ? `${field.id}-label` : undefined;
115
+
116
+ /** `id` for the first part of a kind, `id-2`, `id-3`… for the next ones. */
117
+ export const numbered = (id: string, index: number) => (index === 0 ? id : `${id}-${index + 1}`);
118
+
119
+ /** The index of the next part of `kind` in `field` (render order, the same on both sides). */
120
+ export function nextIndex(field: FieldContextValue, kind: string): number {
121
+ const index = field.rendered.get(kind) ?? 0;
122
+ field.rendered.set(kind, index + 1);
123
+ return index;
124
+ }
125
+
126
+ export const useField = () => useContext(FieldContext);
127
+
128
+ /**
129
+ * A control's wiring from its field, for the props the control was given: its own `id`, `name`,
130
+ * `disabled` and ARIA references win.
131
+ */
132
+ export function fieldProps(
133
+ field: FieldContextValue | undefined,
134
+ own: {
135
+ id?: string;
136
+ name?: string;
137
+ disabled?: boolean;
138
+ "aria-describedby"?: string | number | boolean;
139
+ "aria-invalid"?: string | number | boolean;
140
+ },
141
+ ): {
142
+ id?: string;
143
+ name?: string;
144
+ disabled?: boolean;
145
+ "aria-describedby"?: string | number | boolean;
146
+ "aria-invalid"?: string | number | boolean;
147
+ } {
148
+ if (!field) return {};
149
+ return {
150
+ id: own.id ?? controlId(field),
151
+ name: own.name ?? field.name(),
152
+ disabled: own.disabled || field.disabled() || undefined,
153
+ "aria-describedby": own["aria-describedby"] ?? describedBy(field),
154
+ "aria-invalid": own["aria-invalid"] ?? (field.invalid() || undefined),
155
+ };
156
+ }
@@ -0,0 +1,16 @@
1
+ /*
2
+ * The CSS Field needs to work, and nothing else (decision-62): `lu-field` is a block, and the error
3
+ * parts the script hides stay hidden whatever `display` your CSS gives them. Fieldset, Form, Input,
4
+ * Checkbox, Checkbox Group, Radio Group and Switch are native elements and need none. It sits in
5
+ * the `lu.core` layer, so any app CSS wins.
6
+ */
7
+ @layer lu.core, lu.theme;
8
+
9
+ @layer lu.core {
10
+ lu-field {
11
+ display: block;
12
+ }
13
+ lu-field [hidden] {
14
+ display: none !important;
15
+ }
16
+ }
@@ -0,0 +1,422 @@
1
+ // Field's client script (decision-62, decision-63): it shows the browser's constraint validation in
2
+ // the fields' own Error parts, after Base UI and doc-9 section 15. The controls are native (or
3
+ // form-associated), so `required`, `type`, `pattern`, `minlength` and the rest, the `:invalid`
4
+ // state and the form's blocked submission are the platform's; without JavaScript the browser
5
+ // reports the first problem in its own bubble. Where this script runs, for every `lu-field` on
6
+ // the page:
7
+ //
8
+ // - A submit attempt fires `invalid` on each invalid control: a field with an Error part for the
9
+ // problem cancels it (no bubble) and shows it there (otherwise the bubble stays, TASK-475), and
10
+ // focus goes to the first invalid control of the form, in DOM order; a later invalid control
11
+ // without a field shows no bubble meanwhile.
12
+ // Only a submit attempt does: a submit button's click, Enter in a field, `requestSubmit()` or
13
+ // the form's `reportValidity()`. A silent `checkValidity()` shows nothing (TASK-439).
14
+ // - A form action's errors move focus to the first field that shows one: on load, or when an
15
+ // island's errors arrive (TASK-439).
16
+ // - Timing, from the field's `validation-mode`: `onSubmit` (default) shows errors after a submit
17
+ // attempt, `onBlur` also when the control loses focus (a missing value only once it was
18
+ // edited), `onChange` as soon as it is edited. Once shown, the field revalidates as you type.
19
+ // - An Error without children (an `<output for>` the control) shows the browser's message (or the
20
+ // one a `validate` function set); an Error with children shows them. Either shows for any
21
+ // problem, always, or only for its `match` (a `ValidityState` key, read from its id).
22
+ // - Server errors (rendered visible from the Form's `errors`) go away once the value changes.
23
+ // - A `validitychange` event on a control (Field.Root fires it when an async `validate` settles)
24
+ // shows its new verdict, if the field shows its errors by then. On the field itself, with the
25
+ // messages, it brings a form action's errors back, also the same ones after an edit hid them.
26
+ // - The control gets `aria-invalid` and `aria-describedby` for the errors shown and the field's
27
+ // descriptions.
28
+ //
29
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
30
+
31
+ // A form's controls and descendants, read through the prototypes: a control named `elements` or
32
+ // `querySelectorAll` shadows the form's own member (DOM clobbering, TASK-473 L9).
33
+ const formElements = (form: HTMLFormElement): HTMLFormControlsCollection =>
34
+ Object.getOwnPropertyDescriptor(HTMLFormElement.prototype, "elements")!.get!.call(form);
35
+ const formQuery = <E extends Element>(form: HTMLFormElement, selectors: string) =>
36
+ Element.prototype.querySelectorAll.call(form, selectors) as NodeListOf<E>;
37
+
38
+ type Control = HTMLElement & {
39
+ validity: ValidityState;
40
+ validationMessage: string;
41
+ willValidate: boolean;
42
+ form: HTMLFormElement | null;
43
+ };
44
+
45
+ const CONTROL = "input, select, textarea, lu-combobox";
46
+ /** Fields whose errors are on view: after a submit attempt, a blur or an edit (per mode). */
47
+ const shown = new WeakSet<HTMLElement>();
48
+ /** Fields that still show the errors the server rendered: until the value changes. */
49
+ const server = new WeakMap<HTMLElement, boolean>();
50
+ /** Controls that lost focus once. */
51
+ const touched = new WeakSet<Element>();
52
+ /** Controls the user edited (TASK-446, L9): erasing an edit back to the default still counts. */
53
+ const edited = new WeakSet<Element>();
54
+ /** A pending focus move to the first invalid control of a form. */
55
+ let focusing = false;
56
+ /**
57
+ * The form whose submission is being attempted: set by a submit button's click or Enter in a
58
+ * field until the next task (the browser validates within it), and during `requestSubmit()` and
59
+ * `reportValidity()`. `invalid` events outside an attempt (`checkValidity()`) change nothing.
60
+ */
61
+ let attempt: HTMLFormElement | null = null;
62
+
63
+ const fieldOf = (node: EventTarget | null) =>
64
+ node instanceof Element ? node.closest<HTMLElement>("lu-field") : null;
65
+
66
+ function controls(field: HTMLElement): Control[] {
67
+ return [...field.querySelectorAll<Control>(CONTROL)].filter(
68
+ (control) =>
69
+ fieldOf(control) === field &&
70
+ // An Autocomplete's element (`free`) submits nothing: its input is the control.
71
+ !(control.localName === "lu-combobox" && control.hasAttribute("free")) &&
72
+ control.willValidate !== undefined &&
73
+ !/^(submit|reset|button|image|hidden)$/.test((control as HTMLInputElement).type ?? ""),
74
+ );
75
+ }
76
+
77
+ /**
78
+ * The field's Error parts: `<id>-error` (the message), `<id>-error-invalid` (any problem),
79
+ * `<id>-error-always`, and `<id>-error-<ValidityState key>`.
80
+ */
81
+ function errors(field: HTMLElement): HTMLElement[] {
82
+ return [
83
+ ...field.querySelectorAll<HTMLElement>(`[id^="${CSS.escape(`${field.id}-error`)}"]`),
84
+ ].filter((error) => fieldOf(error) === field);
85
+ }
86
+
87
+ /**
88
+ * What an Error part shows for: "" (any problem), "always", or a `ValidityState` key; a second
89
+ * Error of a kind ends in `-2` (TASK-446, L14).
90
+ */
91
+ const matchOf = (field: HTMLElement, error: HTMLElement) =>
92
+ error.id.slice(`${field.id}-error`.length + 1).replace(/-?\d+$/, "");
93
+
94
+ /**
95
+ * Puts `message` in an `<output>` Error: into its text node, which Lumiose renders and updates with
96
+ * the next server error in an island, or a new one.
97
+ */
98
+ function write(output: HTMLElement, message: string): void {
99
+ const text = [...output.childNodes].find((node): node is Text => node.nodeType === 3);
100
+ if (text) text.data = message;
101
+ else output.append(message);
102
+ }
103
+
104
+ /** Writes the field's errors, `aria-invalid` and `aria-describedby` from its controls' validity. */
105
+ function render(field: HTMLElement): void {
106
+ const all = controls(field);
107
+ const invalid = shown.has(field) ? all.find((control) => !control.validity.valid) : undefined;
108
+ const fromServer = server.get(field) === true;
109
+ const visible: string[] = [];
110
+ for (const error of errors(field)) {
111
+ const match = matchOf(field, error);
112
+ let show: boolean;
113
+ if (match === "always") show = true;
114
+ else if (invalid) {
115
+ show =
116
+ match === "" ||
117
+ match === "invalid" ||
118
+ invalid.validity[match as keyof ValidityState] === true;
119
+ // A message Error is `for` the control; one with children keeps them.
120
+ if (show && error.hasAttribute("for")) write(error, invalid.validationMessage);
121
+ } else show = fromServer && !error.hidden;
122
+ error.hidden = !show;
123
+ if (show) visible.push(error.id);
124
+ }
125
+ const descriptions = [
126
+ ...field.querySelectorAll<HTMLElement>(`[id^="${CSS.escape(`${field.id}-description`)}"]`),
127
+ ]
128
+ .filter((description) => fieldOf(description) === field)
129
+ .map((description) => description.id);
130
+ const done = new Set<Element>();
131
+ for (const control of all) {
132
+ const bad = (invalid !== undefined && !control.validity.valid) || fromServer;
133
+ // A radio or checkbox group is described and marked as one; lu-combobox through its input.
134
+ const group = control.closest<HTMLElement>("[role=radiogroup], [role=group]");
135
+ const target =
136
+ control.localName === "lu-combobox"
137
+ ? (control.querySelector<HTMLElement>("input[role=combobox]") ?? control)
138
+ : group && fieldOf(group) === field
139
+ ? group
140
+ : control;
141
+ if (done.has(target)) continue;
142
+ done.add(target);
143
+ if (bad) target.setAttribute("aria-invalid", "true");
144
+ else target.removeAttribute("aria-invalid");
145
+ const ids = [...descriptions, ...visible];
146
+ if (ids.length > 0) target.setAttribute("aria-describedby", ids.join(" "));
147
+ else target.removeAttribute("aria-describedby");
148
+ }
149
+ }
150
+
151
+ /** Takes a field over the first time the script meets it: its server errors and descriptions. */
152
+ function meet(field: HTMLElement): void {
153
+ if (server.has(field)) return;
154
+ server.set(
155
+ field,
156
+ errors(field).some((error) => !error.hidden && matchOf(field, error) !== "always"),
157
+ );
158
+ render(field);
159
+ }
160
+
161
+ function on<K extends keyof DocumentEventMap>(
162
+ type: K,
163
+ listener: (event: DocumentEventMap[K]) => void,
164
+ capture = true,
165
+ ) {
166
+ document.addEventListener(type, listener, capture);
167
+ }
168
+
169
+ function attempting(form: HTMLFormElement | null | undefined): void {
170
+ if (!form) return;
171
+ attempt = form;
172
+ setTimeout(() => {
173
+ if (attempt === form) attempt = null;
174
+ });
175
+ }
176
+
177
+ on("click", (event) => {
178
+ const button = (event.target as Element).closest?.<HTMLButtonElement | HTMLInputElement>(
179
+ "button, input[type=submit], input[type=image]",
180
+ );
181
+ if (button && /^(submit|image)$/.test(button.type)) attempting(button.form);
182
+ });
183
+
184
+ on("keydown", (event) => {
185
+ const target = event.target as HTMLInputElement;
186
+ // Implicit submission: Enter in a text field submits its form.
187
+ if (event.key === "Enter" && target instanceof HTMLInputElement) attempting(target.form);
188
+ });
189
+
190
+ /** Runs `call` as a submit attempt of `form`. */
191
+ function during<T>(form: HTMLFormElement, call: () => T): T {
192
+ const outer = attempt;
193
+ attempt = form;
194
+ try {
195
+ return call();
196
+ } finally {
197
+ attempt = outer;
198
+ }
199
+ }
200
+
201
+ // `requestSubmit()` and `reportValidity()` are submit attempts too. They are wrapped once per page
202
+ // (TASK-473, L10): a second copy of this script (HMR, two versions of the package) leaves the
203
+ // first copy's wrappers alone, whose listeners already handle every field on the page. A wrapper
204
+ // keeps the native method's name, and a method the browser lacks stays missing.
205
+ const WRAPPED = Symbol.for("lumiose.ui.field.attempts");
206
+ const formPrototype = HTMLFormElement.prototype as HTMLFormElement & { [WRAPPED]?: true };
207
+ if (!formPrototype[WRAPPED]) {
208
+ Object.defineProperty(formPrototype, WRAPPED, { value: true });
209
+ for (const name of ["requestSubmit", "reportValidity"] as const) {
210
+ const native: unknown = formPrototype[name];
211
+ if (typeof native !== "function") continue;
212
+ const wrapper = {
213
+ [name](this: HTMLFormElement, ...args: unknown[]): unknown {
214
+ return during(this, () => native.apply(this, args));
215
+ },
216
+ }[name];
217
+ Object.defineProperty(formPrototype, name, {
218
+ configurable: true,
219
+ writable: true,
220
+ value: wrapper,
221
+ });
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Whether one of `field`'s Error parts shows `control`'s current problem: one for any problem or
227
+ * for this `ValidityState` key. A field whose only Error is for `tooShort` leaves a missing value
228
+ * to the browser's bubble (TASK-475).
229
+ */
230
+ function shows(field: HTMLElement, control: Control): boolean {
231
+ return errors(field).some((error) => {
232
+ const match = matchOf(field, error);
233
+ return (
234
+ match === "" ||
235
+ match === "invalid" ||
236
+ (match !== "always" && control.validity[match as keyof ValidityState] === true)
237
+ );
238
+ });
239
+ }
240
+
241
+ /** Whether a field with an Error part shows `control`'s problem. */
242
+ const handled = (control: Element) => {
243
+ const field = fieldOf(control);
244
+ return field !== null && shows(field, control as Control);
245
+ };
246
+
247
+ /** The control focus goes to: lu-combobox's input, or the control. */
248
+ const focusTarget = (control: Element) =>
249
+ control.localName === "lu-combobox"
250
+ ? (control.querySelector<HTMLElement>("input[role=combobox]") ?? (control as HTMLElement))
251
+ : (control as HTMLElement);
252
+
253
+ const firstInvalid = (form: HTMLFormElement) =>
254
+ [...(formElements(form) as unknown as Control[])].find(
255
+ (element) => element.willValidate && !element.validity.valid,
256
+ );
257
+
258
+ // `invalid` does not bubble: a capturing listener sees it.
259
+ on("invalid", (event) => {
260
+ const control = event.target as Control;
261
+ const form = control.form;
262
+ if (!form || attempt !== form) return;
263
+ const field = fieldOf(control);
264
+ if (!field || errors(field).length === 0) {
265
+ // A later control without a field: no bubble while focus goes to the first, a field's.
266
+ const first = firstInvalid(form);
267
+ if (first && first !== control && handled(first)) event.preventDefault();
268
+ return;
269
+ }
270
+ meet(field);
271
+ shown.add(field);
272
+ render(field);
273
+ if (!shows(field, control)) {
274
+ // No Error part for this problem: the browser's bubble shows it, unless an earlier control's
275
+ // field shows its own and takes focus.
276
+ const first = firstInvalid(form);
277
+ if (first && first !== control && handled(first)) event.preventDefault();
278
+ return;
279
+ }
280
+ // The field shows the problem: no bubble from the browser.
281
+ event.preventDefault();
282
+ if (focusing) return;
283
+ focusing = true;
284
+ queueMicrotask(() => {
285
+ focusing = false;
286
+ const first = firstInvalid(form);
287
+ if (first && handled(first) && document.activeElement !== focusTarget(first)) {
288
+ focusTarget(first).focus();
289
+ }
290
+ });
291
+ });
292
+
293
+ /** Fields that just got a form action's errors, for one focus move after them. */
294
+ const arrived = new Set<HTMLElement>();
295
+
296
+ /** Focuses the first control, in document order, of `fields`. */
297
+ function focusFirst(fields: Iterable<HTMLElement>): void {
298
+ const first = [...fields]
299
+ .flatMap((field) => controls(field).slice(0, 1))
300
+ .toSorted((a, b) =>
301
+ a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
302
+ )[0];
303
+ if (first) focusTarget(first).focus();
304
+ }
305
+
306
+ on("focusout", (event) => {
307
+ const control = event.target as Control;
308
+ const field = fieldOf(control);
309
+ if (!field || !control.matches?.(CONTROL)) return;
310
+ meet(field);
311
+ touched.add(control);
312
+ const mode = field.getAttribute("validation-mode") ?? "onSubmit";
313
+ if (mode === "onSubmit" || control.validity?.valid !== false) return;
314
+ // A missing value waits until the field was edited, as in Base UI.
315
+ if (control.validity.valueMissing && !edited.has(control)) return;
316
+ shown.add(field);
317
+ render(field);
318
+ });
319
+
320
+ function onEdit(event: Event) {
321
+ const field = fieldOf(event.target);
322
+ if (!field) return;
323
+ meet(field);
324
+ edited.add(event.target as Element);
325
+ // A new value: the server's verdict no longer applies.
326
+ server.set(field, false);
327
+ if (field.getAttribute("validation-mode") === "onChange") shown.add(field);
328
+ render(field);
329
+ }
330
+
331
+ /** A form action's errors for the field (an island's Field.Root): shown as the server's again. */
332
+ function showServerErrors(field: HTMLElement, messages: string[]): void {
333
+ server.set(field, true);
334
+ for (const error of errors(field)) {
335
+ const match = matchOf(field, error);
336
+ if (match !== "" && match !== "invalid") continue;
337
+ error.hidden = false;
338
+ if (error.hasAttribute("for")) write(error, messages.join(" "));
339
+ }
340
+ }
341
+
342
+ // Not bubbling: a capturing listener sees it.
343
+ document.addEventListener(
344
+ "validitychange",
345
+ (event) => {
346
+ const field = fieldOf(event.target);
347
+ if (!field) return;
348
+ meet(field);
349
+ const detail = (event as CustomEvent<{ errors?: string[] } | null>).detail;
350
+ if (event.target === field && detail?.errors) {
351
+ showServerErrors(field, detail.errors);
352
+ if (arrived.size === 0) {
353
+ queueMicrotask(() => {
354
+ focusFirst(arrived);
355
+ arrived.clear();
356
+ });
357
+ }
358
+ arrived.add(field);
359
+ }
360
+ render(field);
361
+ },
362
+ true,
363
+ );
364
+
365
+ // After the component's own listeners, which run a `validate` function first.
366
+ on("input", onEdit, false);
367
+ on("change", onEdit, false);
368
+ on("reset", (event) => {
369
+ const form = event.target as HTMLFormElement;
370
+ // The controls take their default values after the event.
371
+ queueMicrotask(() => {
372
+ for (const field of formQuery<HTMLElement>(form, "lu-field")) {
373
+ shown.delete(field);
374
+ server.set(field, false);
375
+ render(field);
376
+ }
377
+ });
378
+ });
379
+
380
+ // A control that becomes disabled (its own attribute, a Field's, a fieldset's) is barred from
381
+ // validation: a field whose controls are all disabled shows no error, the server's included
382
+ // (TASK-446, L10).
383
+ new MutationObserver((records) => {
384
+ const fields = new Set<HTMLElement>();
385
+ for (const { target } of records) {
386
+ const element = target as Element;
387
+ const own = fieldOf(element);
388
+ if (own) fields.add(own);
389
+ if (element.localName === "fieldset" || element.localName === "lu-field") {
390
+ for (const field of element.querySelectorAll<HTMLElement>("lu-field")) fields.add(field);
391
+ }
392
+ }
393
+ for (const field of fields) {
394
+ if (!server.has(field)) continue;
395
+ if (controls(field).every((control) => control.matches(":disabled"))) {
396
+ shown.delete(field);
397
+ server.set(field, false);
398
+ }
399
+ render(field);
400
+ }
401
+ }).observe(document.documentElement, {
402
+ attributes: true,
403
+ attributeFilter: ["disabled"],
404
+ subtree: true,
405
+ });
406
+
407
+ function meetAll() {
408
+ const fields = [...document.querySelectorAll<HTMLElement>("lu-field")];
409
+ for (const field of fields) meet(field);
410
+ // A page rendered back with a form action's errors: focus goes to the first, unless something
411
+ // else (`autofocus`) has it.
412
+ if (document.activeElement === document.body || document.activeElement === null) {
413
+ focusFirst(fields.filter((field) => server.get(field) === true));
414
+ }
415
+ }
416
+ if (document.readyState === "loading") {
417
+ document.addEventListener("DOMContentLoaded", meetAll, { once: true });
418
+ } else queueMicrotask(meetAll);
419
+
420
+ // A module, so it runs once per page whoever imports it.
421
+ // oxlint-disable-next-line unicorn/require-module-specifiers
422
+ export {};