@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,974 @@
|
|
|
1
|
+
// Combobox's client script (decision-62, shipped through the client-script import of decision-63):
|
|
2
|
+
// it defines `<lu-combobox>`, the light-DOM, form-associated element that Combobox.Root renders. A
|
|
3
|
+
// combobox has no native equivalent (`<datalist>` cannot be styled or grouped, and customizable
|
|
4
|
+
// select cannot filter: doc-9 section 6), so the element owns the behaviour, after the APG
|
|
5
|
+
// combobox pattern with a listbox popup, list autocomplete and manual selection, and Base UI:
|
|
6
|
+
//
|
|
7
|
+
// - The parts are found through their ARIA wiring: the `input[role=combobox]`, the listbox its
|
|
8
|
+
// `aria-controls` names, the popover around the listbox (the listbox itself, or `Combobox.Popup`),
|
|
9
|
+
// the `[role=option]`s with `data-value`. Buttons act through invoker commands to the element
|
|
10
|
+
// (`command="--toggle"`, `"--clear"`), and the chips and empty state by their ids (`<id>-chips`,
|
|
11
|
+
// `<id>-empty`).
|
|
12
|
+
// - Value and label: `value` is the selected option's `data-value` (an array with `multiple`), the
|
|
13
|
+
// input shows its label (`data-label` or its text). Typing filters the options (contains, or
|
|
14
|
+
// `filter="startsWith"`; `filter="none"` leaves it to the page) and never commits: only choosing an
|
|
15
|
+
// option does, and fires `change`, like a native control (the inner input's own `change` stays
|
|
16
|
+
// inside). Closing without a choice puts the label back; emptying the input clears the choice.
|
|
17
|
+
// - Form: the element submits `value` under its `name` through ElementInternals (every value its own
|
|
18
|
+
// entry with `multiple`), is `required` until it has a value, resets with its form and follows a
|
|
19
|
+
// disabled fieldset.
|
|
20
|
+
// - Focus stays in the input: options are highlighted through `aria-activedescendant` and
|
|
21
|
+
// `data-highlighted`, and presses in the popup keep focus where it is.
|
|
22
|
+
// - Chips (`multiple`): the arrow toward them at the input's start moves into them, the arrows move
|
|
23
|
+
// between them and back to the input, and Backspace or Delete removes the focused one (TASK-461).
|
|
24
|
+
// - The popup is a manual popover dismissed by the shared layer model (../layer/layer.ts): an
|
|
25
|
+
// outside press with the main button, a close request, Tab, Escape (which then stops there, so a
|
|
26
|
+
// parent dialog stays open), or focus moving elsewhere. It is placed by anchor positioning, or by
|
|
27
|
+
// ../placement/place.ts where that is missing.
|
|
28
|
+
//
|
|
29
|
+
// With `free` (Autocomplete, TASK-377), the input's text is the value, after the APG combobox with
|
|
30
|
+
// list autocomplete and Base UI's Autocomplete: the input itself has the name and submits the text,
|
|
31
|
+
// typing never has to match an option, and choosing one puts its value in the input (firing
|
|
32
|
+
// `input` and `change` on it). The popup opens as one types (on a click only with
|
|
33
|
+
// `open-on-input-click`) and closes when the text is emptied. With `inline`, the arrow keys show
|
|
34
|
+
// the highlighted option's text in the input until Escape puts the typed text back. More of Base
|
|
35
|
+
// UI's Autocomplete (TASK-410): `auto-highlight` highlights the first match as one types (Enter
|
|
36
|
+
// then chooses it); without `keep-highlight`, the pointer leaving the list takes the highlight it
|
|
37
|
+
// gave away; `submit-on-item-click` submits the form when a click chooses an option.
|
|
38
|
+
//
|
|
39
|
+
// `limit` shows at most that many matching options (any mode).
|
|
40
|
+
//
|
|
41
|
+
// Listeners are delegated and options are queried when an event needs them, so Lumiose may add,
|
|
42
|
+
// remove or rerender options at any time; a mutation observer gives new options the selection and
|
|
43
|
+
// the current search, and opens the popup when results arrive while the user types (async
|
|
44
|
+
// options). It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
45
|
+
|
|
46
|
+
import { moved, openLayer } from "../layer/layer.ts";
|
|
47
|
+
import { anchoring, place } from "../placement/place.ts";
|
|
48
|
+
|
|
49
|
+
/** Submits `form` as its submit button would. Through the prototype: a control named
|
|
50
|
+
* `requestSubmit` shadows the form's own (DOM clobbering, TASK-473 L9). */
|
|
51
|
+
const submit = (form: HTMLFormElement) => HTMLFormElement.prototype.requestSubmit.call(form);
|
|
52
|
+
|
|
53
|
+
const OPTION = "[role=option]";
|
|
54
|
+
|
|
55
|
+
export interface ComboboxOpenChangeDetail {
|
|
56
|
+
open: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How typing filters the options. */
|
|
60
|
+
export type ComboboxFilter = "contains" | "startsWith" | "none";
|
|
61
|
+
|
|
62
|
+
/** Invoker commands (Chrome 135, Firefox 144, Safari 26.2); elsewhere clicks stand in. */
|
|
63
|
+
const invokers =
|
|
64
|
+
typeof HTMLButtonElement !== "undefined" && "command" in HTMLButtonElement.prototype;
|
|
65
|
+
|
|
66
|
+
/** The events the element listens to on itself. */
|
|
67
|
+
const EVENTS = [
|
|
68
|
+
"input",
|
|
69
|
+
"compositionend",
|
|
70
|
+
"keydown",
|
|
71
|
+
"click",
|
|
72
|
+
"pointerdown",
|
|
73
|
+
"pointermove",
|
|
74
|
+
"pointerout",
|
|
75
|
+
"focusout",
|
|
76
|
+
"change",
|
|
77
|
+
"command",
|
|
78
|
+
"invalid",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const fold = (text: string) =>
|
|
82
|
+
text
|
|
83
|
+
.normalize("NFD")
|
|
84
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
85
|
+
.toLocaleLowerCase();
|
|
86
|
+
|
|
87
|
+
const disabled = (option: Element) => option.getAttribute("aria-disabled") === "true";
|
|
88
|
+
|
|
89
|
+
/** The text an option shows in the input and matches when typing. */
|
|
90
|
+
export const optionLabel = (option: HTMLElement) =>
|
|
91
|
+
(option.dataset.label ?? option.textContent ?? "").trim();
|
|
92
|
+
|
|
93
|
+
/** The browser's own "select an item" message, in the page's language. */
|
|
94
|
+
let missing: string | undefined;
|
|
95
|
+
|
|
96
|
+
export class ComboboxElement extends HTMLElement {
|
|
97
|
+
static formAssociated = true;
|
|
98
|
+
static observedAttributes = ["name", "required", "multiple", "disabled"];
|
|
99
|
+
|
|
100
|
+
#internals = this.attachInternals();
|
|
101
|
+
#values: string[] = [];
|
|
102
|
+
/** The labels of the selected values: options may go away (async options) while selected. */
|
|
103
|
+
#labels = new Map<string, string>();
|
|
104
|
+
/** The selection the form resets to. */
|
|
105
|
+
#defaults: string[] | undefined;
|
|
106
|
+
/** Stops dismissing the popup (../layer/layer.ts). */
|
|
107
|
+
#release: (() => void) | undefined;
|
|
108
|
+
/** Whether a disabled fieldset around the element disables it. */
|
|
109
|
+
#fieldsetDisabled = false;
|
|
110
|
+
#observer: MutationObserver | undefined;
|
|
111
|
+
/** With `free`: the text as typed, which inline completion and Escape go back to. */
|
|
112
|
+
#typed = "";
|
|
113
|
+
/** The highlight came from the pointer (it goes when the pointer leaves the list). */
|
|
114
|
+
#pointed = false;
|
|
115
|
+
/** With `free`: an `input` event the element fires itself, which is no typing. */
|
|
116
|
+
#echo = false;
|
|
117
|
+
/** When the popup last closed: an outside invoker's toggle right after keeps it shut. */
|
|
118
|
+
#dismissed = Number.NEGATIVE_INFINITY;
|
|
119
|
+
|
|
120
|
+
/** Whether the input's text is the value (Autocomplete). */
|
|
121
|
+
get #free(): boolean {
|
|
122
|
+
return this.hasAttribute("free");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The text field, `input[role=combobox]`. */
|
|
126
|
+
get input(): HTMLInputElement | null {
|
|
127
|
+
return this.querySelector<HTMLInputElement>("input[role=combobox]");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The listbox of options, named by the input's `aria-controls`. */
|
|
131
|
+
get listbox(): HTMLElement | null {
|
|
132
|
+
const id = this.input?.getAttribute("aria-controls");
|
|
133
|
+
return id ? this.querySelector<HTMLElement>(`[id="${CSS.escape(id)}"]`) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The popover that shows the listbox: the listbox itself, or a popup around it. */
|
|
137
|
+
get popup(): HTMLElement | null {
|
|
138
|
+
const popup = this.listbox?.closest<HTMLElement>("[popover]");
|
|
139
|
+
return popup && this.contains(popup) ? popup : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether several options can be selected; reflects the `multiple` attribute. */
|
|
143
|
+
get multiple(): boolean {
|
|
144
|
+
return this.hasAttribute("multiple");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
set multiple(on: boolean) {
|
|
148
|
+
this.toggleAttribute("multiple", on);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Whether the form needs a value; reflects the `required` attribute. */
|
|
152
|
+
get required(): boolean {
|
|
153
|
+
return this.hasAttribute("required");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
set required(on: boolean) {
|
|
157
|
+
this.toggleAttribute("required", on);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Whether the combobox is disabled; reflects the `disabled` attribute. */
|
|
161
|
+
get disabled(): boolean {
|
|
162
|
+
return this.hasAttribute("disabled");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
set disabled(on: boolean) {
|
|
166
|
+
this.toggleAttribute("disabled", on);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The selected option's value, `""` when none is; with `multiple`, the selected values. Setting
|
|
171
|
+
* it selects those options without a `change` event.
|
|
172
|
+
*/
|
|
173
|
+
get value(): string | string[] {
|
|
174
|
+
return this.multiple ? [...this.#values] : (this.#values[0] ?? "");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
set value(next: string | readonly string[] | null | undefined) {
|
|
178
|
+
const values = next == null ? [] : typeof next === "string" ? (next ? [next] : []) : [...next];
|
|
179
|
+
this.#set(this.multiple ? values : values.slice(0, 1), false);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether the popup is open. Setting it opens or closes the popup. */
|
|
183
|
+
get open(): boolean {
|
|
184
|
+
return this.popup?.matches(":popover-open") ?? false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
set open(value: boolean | undefined) {
|
|
188
|
+
if (typeof value !== "boolean" || value === this.open) return;
|
|
189
|
+
if (value) this.#show(true);
|
|
190
|
+
else this.#close();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The name the value is submitted under; reflects the `name` attribute. */
|
|
194
|
+
get name(): string {
|
|
195
|
+
return this.getAttribute("name") ?? "";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
set name(name: string) {
|
|
199
|
+
this.setAttribute("name", name);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
get form(): HTMLFormElement | null {
|
|
203
|
+
return this.#internals.form;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
get validity(): ValidityState {
|
|
207
|
+
return this.#internals.validity;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
get validationMessage(): string {
|
|
211
|
+
return this.#internals.validationMessage;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
get willValidate(): boolean {
|
|
215
|
+
return this.#internals.willValidate;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
checkValidity(): boolean {
|
|
219
|
+
return this.#internals.checkValidity();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
reportValidity(): boolean {
|
|
223
|
+
return this.#internals.reportValidity();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
connectedCallback(): void {
|
|
227
|
+
for (const type of EVENTS) this.addEventListener(type, this);
|
|
228
|
+
// `toggle` does not bubble: a capturing listener on the element still sees it.
|
|
229
|
+
this.addEventListener("toggle", this, true);
|
|
230
|
+
this.#observer ??= new MutationObserver((records) => this.#onMutation(records));
|
|
231
|
+
this.#observer.observe(this, { childList: true, subtree: true });
|
|
232
|
+
if (this.#defaults) return;
|
|
233
|
+
// The server's selection, once the element's content is parsed: the options rendered selected.
|
|
234
|
+
const settle = () => {
|
|
235
|
+
if (this.#defaults) return;
|
|
236
|
+
// Text typed before the script ran is the user's: kept, not the selection's label (M24).
|
|
237
|
+
const input = this.input;
|
|
238
|
+
const typed = input && input.value !== input.defaultValue ? input.value : undefined;
|
|
239
|
+
if (this.#free) this.#typed = this.input?.value ?? "";
|
|
240
|
+
this.#set(this.#serverDefaults(), false);
|
|
241
|
+
if (typed !== undefined && input && !this.#free) {
|
|
242
|
+
input.value = typed;
|
|
243
|
+
this.#filter(typed);
|
|
244
|
+
this.#showClear();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
if (document.readyState === "loading") {
|
|
248
|
+
document.addEventListener("DOMContentLoaded", settle, { once: true });
|
|
249
|
+
} else queueMicrotask(settle);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
disconnectedCallback(): void {
|
|
253
|
+
for (const type of EVENTS) this.removeEventListener(type, this);
|
|
254
|
+
this.removeEventListener("toggle", this, true);
|
|
255
|
+
this.#observer?.disconnect();
|
|
256
|
+
this.#follow(false);
|
|
257
|
+
this.#release?.();
|
|
258
|
+
this.#release = undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
attributeChangedCallback(name: string): void {
|
|
262
|
+
if (name === "disabled" && this.disabled) this.#close();
|
|
263
|
+
if (this.#defaults) this.#sync();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
formResetCallback(): void {
|
|
267
|
+
// A reset takes back the submit attempt's verdict; in a field, Field's script does (L32).
|
|
268
|
+
if (!this.closest("lu-field")) this.input?.removeAttribute("aria-invalid");
|
|
269
|
+
if (this.#free) {
|
|
270
|
+
// The input resets itself, after the element.
|
|
271
|
+
queueMicrotask(() => this.#showClear());
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (!this.#defaults) return;
|
|
275
|
+
this.#set(this.#defaults, false);
|
|
276
|
+
// The input resets after the element, to its own default: put the label back after it.
|
|
277
|
+
queueMicrotask(() => this.#showLabel());
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
formDisabledCallback(off: boolean): void {
|
|
281
|
+
this.#fieldsetDisabled = off;
|
|
282
|
+
if (off) this.#close();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The server's selection, from the options it rendered selected: the reset default. Read once,
|
|
287
|
+
* before a restored state changes the options (TASK-460).
|
|
288
|
+
*/
|
|
289
|
+
#serverDefaults(): string[] {
|
|
290
|
+
if (!this.#defaults) {
|
|
291
|
+
const selected = this.#options().filter((o) => o.getAttribute("aria-selected") === "true");
|
|
292
|
+
for (const option of selected) {
|
|
293
|
+
this.#labels.set(option.dataset.value ?? "", optionLabel(option));
|
|
294
|
+
}
|
|
295
|
+
const values = selected.map((option) => option.dataset.value ?? "");
|
|
296
|
+
this.#defaults = this.multiple ? values : values.slice(0, 1);
|
|
297
|
+
}
|
|
298
|
+
return this.#defaults;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
formStateRestoreCallback(state: string | FormData | null): void {
|
|
302
|
+
if (this.#free) return;
|
|
303
|
+
// A back navigation restores the user's choice, which must not become the reset default: the
|
|
304
|
+
// server's selection is read first, and the element's start then leaves the choice as it is.
|
|
305
|
+
this.#serverDefaults();
|
|
306
|
+
const values =
|
|
307
|
+
state instanceof FormData
|
|
308
|
+
? state.getAll(this.name).map(String)
|
|
309
|
+
: typeof state === "string" && state
|
|
310
|
+
? [state]
|
|
311
|
+
: [];
|
|
312
|
+
this.#set(values, false);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
handleEvent(event: Event): void {
|
|
316
|
+
switch (event.type) {
|
|
317
|
+
case "input":
|
|
318
|
+
if (event.target === this.input) this.#onInput(event as InputEvent);
|
|
319
|
+
return;
|
|
320
|
+
case "compositionend":
|
|
321
|
+
// The committed text is the search: an IME composition filtered nothing (TASK-438, M21).
|
|
322
|
+
if (event.target === this.input) this.#onInput();
|
|
323
|
+
return;
|
|
324
|
+
case "keydown":
|
|
325
|
+
return this.#onKeyDown(event as KeyboardEvent);
|
|
326
|
+
case "click":
|
|
327
|
+
return this.#onClick(event as MouseEvent);
|
|
328
|
+
case "pointerdown":
|
|
329
|
+
return this.#onPress(event as PointerEvent);
|
|
330
|
+
case "pointermove":
|
|
331
|
+
return this.#onPointerMove(event as PointerEvent);
|
|
332
|
+
case "pointerout": {
|
|
333
|
+
// With `free`, the pointer leaving the list takes away the highlight it gave.
|
|
334
|
+
const list = this.listbox;
|
|
335
|
+
const to = (event as PointerEvent).relatedTarget as Node | null;
|
|
336
|
+
if (!this.#free || !this.#pointed || this.hasAttribute("keep-highlight")) return;
|
|
337
|
+
if (list?.contains(event.target as Node) && !list.contains(to)) {
|
|
338
|
+
this.#pointed = false;
|
|
339
|
+
this.#highlight(undefined);
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
case "focusout": {
|
|
344
|
+
// With `free`, leaving the input accepts an inline completion.
|
|
345
|
+
const input = this.input;
|
|
346
|
+
if (this.#free && input && event.target === input && input.value !== this.#typed) {
|
|
347
|
+
this.#complete(input.value, false);
|
|
348
|
+
}
|
|
349
|
+
// Focus moving to something else on the page (Tab, a click on a field) closes the popup.
|
|
350
|
+
// Focus going nowhere (a click on the page, another window) leaves it to the outside-press
|
|
351
|
+
// rules: a right-click outside keeps it open.
|
|
352
|
+
const to = (event as FocusEvent).relatedTarget as Node | null;
|
|
353
|
+
if (to && !this.contains(to)) this.#close();
|
|
354
|
+
// Text typed after the popup closed itself (nothing matched) is no choice: the label of
|
|
355
|
+
// the selection comes back when focus leaves (TASK-438, M19).
|
|
356
|
+
else if (!to && event.target === input && !this.open) this.#showLabel();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
case "change":
|
|
360
|
+
// The inner input's `change` reports typed text, not a choice: it stays inside (with
|
|
361
|
+
// `free`, the text is the value: it goes on).
|
|
362
|
+
if (event.target !== this && !this.#free) event.stopPropagation();
|
|
363
|
+
return;
|
|
364
|
+
case "command": {
|
|
365
|
+
const { command, source } = event as Event & { command: string; source?: Element | null };
|
|
366
|
+
return this.#onCommand(command, source ?? null);
|
|
367
|
+
}
|
|
368
|
+
case "invalid":
|
|
369
|
+
this.input?.setAttribute("aria-invalid", "true");
|
|
370
|
+
return;
|
|
371
|
+
case "toggle":
|
|
372
|
+
return this.#onToggle(event as ToggleEvent);
|
|
373
|
+
case "scroll":
|
|
374
|
+
case "resize":
|
|
375
|
+
return this.#place();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Parts -------------------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
#options(): HTMLElement[] {
|
|
382
|
+
return [...(this.listbox?.querySelectorAll<HTMLElement>(OPTION) ?? [])];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The options the arrow keys reach: shown and enabled. */
|
|
386
|
+
#reachable(): HTMLElement[] {
|
|
387
|
+
return this.#options().filter((option) => !option.hidden && !disabled(option));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#part(suffix: string): HTMLElement | null {
|
|
391
|
+
return this.id ? document.getElementById(`${this.id}-${suffix}`) : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#highlighted(): HTMLElement | null {
|
|
395
|
+
const id = this.input?.getAttribute("aria-activedescendant");
|
|
396
|
+
return id ? this.querySelector<HTMLElement>(`[id="${CSS.escape(id)}"]`) : null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#disabled(): boolean {
|
|
400
|
+
return this.disabled || this.#fieldsetDisabled || !!this.input?.disabled;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// State -------------------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/** Selects `values`, reporting a change with `change` when `report` and they differ. */
|
|
406
|
+
#set(values: string[], report: boolean): void {
|
|
407
|
+
const before = this.#values;
|
|
408
|
+
this.#values = [...new Set(values)];
|
|
409
|
+
for (const option of this.#options()) {
|
|
410
|
+
if (this.#values.includes(option.dataset.value ?? "")) {
|
|
411
|
+
this.#labels.set(option.dataset.value ?? "", optionLabel(option));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const key of this.#labels.keys())
|
|
415
|
+
if (!this.#values.includes(key)) this.#labels.delete(key);
|
|
416
|
+
this.#sync();
|
|
417
|
+
if (!this.open) this.#showLabel();
|
|
418
|
+
const changed =
|
|
419
|
+
before.length !== this.#values.length || before.some((v, i) => v !== this.#values[i]);
|
|
420
|
+
if (report && changed) {
|
|
421
|
+
this.input?.removeAttribute("aria-invalid");
|
|
422
|
+
this.dispatchEvent(new Event("change", { bubbles: true }));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Writes the selection to the options, the chips, the clear buttons and the form. */
|
|
427
|
+
#sync(): void {
|
|
428
|
+
if (this.#free) {
|
|
429
|
+
// The input is the form control: the element submits nothing and is always valid.
|
|
430
|
+
this.#internals.setFormValue(null);
|
|
431
|
+
this.#internals.setValidity({});
|
|
432
|
+
this.#showClear();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
for (const option of this.#options()) {
|
|
436
|
+
option.setAttribute(
|
|
437
|
+
"aria-selected",
|
|
438
|
+
String(this.#values.includes(option.dataset.value ?? "")),
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
this.#renderChips();
|
|
442
|
+
this.#showClear();
|
|
443
|
+
const name = this.name;
|
|
444
|
+
if (this.multiple && name) {
|
|
445
|
+
const data = new FormData();
|
|
446
|
+
for (const value of this.#values) data.append(name, value);
|
|
447
|
+
this.#internals.setFormValue(data);
|
|
448
|
+
} else this.#internals.setFormValue(this.#values[0] ?? null);
|
|
449
|
+
// The input, which has the role, follows the element's `required` (TASK-431).
|
|
450
|
+
const input = this.input;
|
|
451
|
+
if (input && this.required) input.setAttribute("aria-required", "true");
|
|
452
|
+
else input?.removeAttribute("aria-required");
|
|
453
|
+
if (this.hasAttribute("required") && this.#values.length === 0) {
|
|
454
|
+
missing ??= Object.assign(document.createElement("select"), {
|
|
455
|
+
required: true,
|
|
456
|
+
}).validationMessage;
|
|
457
|
+
this.#internals.setValidity({ valueMissing: true }, missing, this.input ?? undefined);
|
|
458
|
+
} else this.#internals.setValidity({});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** The input shows the selected option's label (single), or nothing (multiple). */
|
|
462
|
+
#showLabel(): void {
|
|
463
|
+
const input = this.input;
|
|
464
|
+
if (!input || this.#free) {
|
|
465
|
+
this.#showClear();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const text = this.multiple ? "" : (this.#labels.get(this.#values[0] ?? "") ?? "");
|
|
469
|
+
if (input.value !== text) input.value = text;
|
|
470
|
+
this.#showClear();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Clear buttons show while there is something to clear. */
|
|
474
|
+
#showClear(): void {
|
|
475
|
+
const empty = this.#values.length === 0 && !this.input?.value;
|
|
476
|
+
for (const button of this.querySelectorAll<HTMLElement>("[command='--clear']")) {
|
|
477
|
+
button.hidden = empty;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** One chip per selected value, in the chips list (`multiple`). */
|
|
482
|
+
#renderChips(): void {
|
|
483
|
+
const list = this.#part("chips");
|
|
484
|
+
if (!list) return;
|
|
485
|
+
const rendered = [...list.children].map((chip) => (chip as HTMLElement).dataset.value);
|
|
486
|
+
if (
|
|
487
|
+
rendered.length === this.#values.length &&
|
|
488
|
+
rendered.every((v, i) => v === this.#values[i])
|
|
489
|
+
) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const remove = list.dataset.removeLabel ?? "Remove";
|
|
493
|
+
list.replaceChildren(
|
|
494
|
+
...this.#values.map((value) => {
|
|
495
|
+
const label = this.#labels.get(value) ?? value;
|
|
496
|
+
const chip = document.createElement("span");
|
|
497
|
+
chip.setAttribute("role", "listitem");
|
|
498
|
+
chip.dataset.value = value;
|
|
499
|
+
// The keys reach it from the input (TASK-461).
|
|
500
|
+
chip.tabIndex = -1;
|
|
501
|
+
const button = document.createElement("button");
|
|
502
|
+
Object.assign(button, { type: "button", tabIndex: -1, textContent: "×", value });
|
|
503
|
+
button.setAttribute("aria-label", `${remove} ${label}`);
|
|
504
|
+
chip.append(label, button);
|
|
505
|
+
return chip;
|
|
506
|
+
}),
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Filtering ---------------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
/** Shows the options that match `query` (all of them for `""`) and the empty state if none do. */
|
|
513
|
+
#filter(query: string): void {
|
|
514
|
+
const mode = (this.getAttribute("filter") ?? "contains") as ComboboxFilter;
|
|
515
|
+
const folded = fold(query.trim());
|
|
516
|
+
const limit = Number.parseInt(this.getAttribute("limit") ?? "", 10);
|
|
517
|
+
let shown = 0;
|
|
518
|
+
for (const option of this.#options()) {
|
|
519
|
+
if (mode === "none" || folded === "") option.hidden = false;
|
|
520
|
+
else {
|
|
521
|
+
const label = fold(optionLabel(option));
|
|
522
|
+
option.hidden = !(mode === "startsWith"
|
|
523
|
+
? label.startsWith(folded)
|
|
524
|
+
: label.includes(folded));
|
|
525
|
+
}
|
|
526
|
+
// Past the limit, a match waits for a narrower search.
|
|
527
|
+
if (!option.hidden && limit >= 0 && ++shown > limit) option.hidden = true;
|
|
528
|
+
}
|
|
529
|
+
// A group with nothing left to show goes too.
|
|
530
|
+
for (const group of this.listbox?.querySelectorAll<HTMLElement>("[role=group]") ?? []) {
|
|
531
|
+
group.hidden = ![...group.querySelectorAll<HTMLElement>(OPTION)].some((o) => !o.hidden);
|
|
532
|
+
}
|
|
533
|
+
const empty = this.#part("empty");
|
|
534
|
+
if (empty) empty.hidden = this.#options().some((option) => !option.hidden);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Whether the popup has anything to show: an option, the empty state, or a Status's message. */
|
|
538
|
+
#showable(): boolean {
|
|
539
|
+
return (
|
|
540
|
+
this.#options().some((option) => !option.hidden) ||
|
|
541
|
+
this.#part("empty") !== null ||
|
|
542
|
+
// A Status in the popup (async options: "Loading…") shows while there is none (M22).
|
|
543
|
+
[...(this.popup?.querySelectorAll<HTMLElement>("[role=status]") ?? [])].some(
|
|
544
|
+
(status) => status.id !== `${this.id}-empty` && status.textContent?.trim() !== "",
|
|
545
|
+
)
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Opening and closing -----------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
/** Opens the popup with every option, highlighting the selected one when `highlight`. */
|
|
552
|
+
#show(highlight: boolean, edge: "first" | "last" = "first"): void {
|
|
553
|
+
if (this.#disabled()) return;
|
|
554
|
+
const input = this.input;
|
|
555
|
+
// The label of the selection is not a search: show every option. A free text is one.
|
|
556
|
+
if (this.#free) this.#filter(this.#typed);
|
|
557
|
+
else if (
|
|
558
|
+
!input ||
|
|
559
|
+
input.value === "" ||
|
|
560
|
+
input.value === this.#labels.get(this.#values[0] ?? "")
|
|
561
|
+
) {
|
|
562
|
+
this.#filter("");
|
|
563
|
+
}
|
|
564
|
+
this.#open();
|
|
565
|
+
if (!this.open || !highlight) return;
|
|
566
|
+
const reachable = this.#reachable();
|
|
567
|
+
const selected = reachable.find((o) => this.#values.includes(o.dataset.value ?? ""));
|
|
568
|
+
this.#highlight(selected ?? (edge === "first" ? reachable[0] : reachable.at(-1)));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#open(): void {
|
|
572
|
+
const popup = this.popup;
|
|
573
|
+
const input = this.input;
|
|
574
|
+
if (!popup || !input || this.open || !this.#showable()) return;
|
|
575
|
+
// `source` makes the input the popover's invoker: focus order and nesting follow it.
|
|
576
|
+
(popup.showPopover as (options?: { source?: HTMLElement }) => void)({ source: input });
|
|
577
|
+
this.#place();
|
|
578
|
+
this.#follow(true);
|
|
579
|
+
this.#release?.();
|
|
580
|
+
this.#release = openLayer({
|
|
581
|
+
popup,
|
|
582
|
+
contains: (node) => this.contains(node),
|
|
583
|
+
dismiss: () => this.#close(),
|
|
584
|
+
});
|
|
585
|
+
// Now, not on `toggle` a task later: `aria-activedescendant` may follow at once (L32).
|
|
586
|
+
this.#expanded(true);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** `aria-expanded` on the input and on the Trigger buttons (TASK-454, L32). */
|
|
590
|
+
#expanded(open: boolean): void {
|
|
591
|
+
this.input?.setAttribute("aria-expanded", String(open));
|
|
592
|
+
for (const button of this.querySelectorAll<HTMLElement>("[command='--toggle']")) {
|
|
593
|
+
button.setAttribute("aria-expanded", String(open));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Closes the popup; `restore` puts the selection's label back in the input. */
|
|
598
|
+
#close(restore = true): void {
|
|
599
|
+
this.#highlight(undefined);
|
|
600
|
+
this.#release?.();
|
|
601
|
+
this.#release = undefined;
|
|
602
|
+
if (this.open) {
|
|
603
|
+
this.popup!.hidePopover();
|
|
604
|
+
this.#dismissed = performance.now();
|
|
605
|
+
}
|
|
606
|
+
this.#expanded(false);
|
|
607
|
+
if (restore) this.#showLabel();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
#onToggle(event: ToggleEvent): void {
|
|
611
|
+
if (event.target !== this.popup) return;
|
|
612
|
+
const open = event.newState === "open";
|
|
613
|
+
this.#expanded(open);
|
|
614
|
+
if (!open) {
|
|
615
|
+
this.#follow(false);
|
|
616
|
+
this.#release?.();
|
|
617
|
+
this.#release = undefined;
|
|
618
|
+
this.#highlight(undefined);
|
|
619
|
+
}
|
|
620
|
+
this.dispatchEvent(
|
|
621
|
+
new CustomEvent<ComboboxOpenChangeDetail>("openchange", { detail: { open } }),
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** While open without anchor positioning, the popup follows the input on scroll and resize. */
|
|
626
|
+
#follow(on: boolean): void {
|
|
627
|
+
if (anchoring) return;
|
|
628
|
+
const method = on ? "addEventListener" : "removeEventListener";
|
|
629
|
+
for (const type of ["scroll", "resize"]) window[method](type, this, true);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
#place(): void {
|
|
633
|
+
const input = this.input;
|
|
634
|
+
if (!anchoring && input && this.open) place(this.popup!, input);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Highlight and choice ----------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
#highlight(option: HTMLElement | undefined): void {
|
|
640
|
+
const current = this.#highlighted();
|
|
641
|
+
if (current === option) return;
|
|
642
|
+
current?.removeAttribute("data-highlighted");
|
|
643
|
+
const input = this.input;
|
|
644
|
+
if (!option) {
|
|
645
|
+
input?.removeAttribute("aria-activedescendant");
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
option.setAttribute("data-highlighted", "");
|
|
649
|
+
input?.setAttribute("aria-activedescendant", option.id);
|
|
650
|
+
option.scrollIntoView?.({ block: "nearest" });
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* With `inline`: the input shows the highlighted option's text, the part past what was typed
|
|
655
|
+
* selected; back on the input (no option), the typed text.
|
|
656
|
+
*/
|
|
657
|
+
#inline(option: HTMLElement | undefined): void {
|
|
658
|
+
const input = this.input;
|
|
659
|
+
if (!input) return;
|
|
660
|
+
const text = option ? (option.dataset.value ?? optionLabel(option)) : this.#typed;
|
|
661
|
+
input.value = text;
|
|
662
|
+
const typed = this.#typed;
|
|
663
|
+
if (option && fold(text).startsWith(fold(typed)))
|
|
664
|
+
input.setSelectionRange(typed.length, text.length);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Chooses `option`: selects it (toggles it with `multiple`). */
|
|
668
|
+
#choose(option: HTMLElement): void {
|
|
669
|
+
if (disabled(option)) return;
|
|
670
|
+
if (this.#free) {
|
|
671
|
+
this.#close(false);
|
|
672
|
+
this.#complete(option.dataset.value ?? optionLabel(option), true);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const value = option.dataset.value ?? "";
|
|
676
|
+
this.#labels.set(value, optionLabel(option));
|
|
677
|
+
if (!this.multiple) {
|
|
678
|
+
this.#set([value], true);
|
|
679
|
+
this.#close();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const values = this.#values.includes(value)
|
|
683
|
+
? this.#values.filter((v) => v !== value)
|
|
684
|
+
: [...this.#values, value];
|
|
685
|
+
// The search is done: clear it and show every option again, keeping the highlight.
|
|
686
|
+
if (this.input) this.input.value = "";
|
|
687
|
+
this.#filter("");
|
|
688
|
+
this.#set(values, true);
|
|
689
|
+
this.#highlight(option);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
#remove(value: string): void {
|
|
693
|
+
this.#set(
|
|
694
|
+
this.#values.filter((v) => v !== value),
|
|
695
|
+
true,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Events ------------------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
/** With `free`: puts `text` in the input, as typing would; `commit` also fires `change`. */
|
|
702
|
+
#complete(text: string, commit: boolean): void {
|
|
703
|
+
const input = this.input;
|
|
704
|
+
if (!input) return;
|
|
705
|
+
input.value = text;
|
|
706
|
+
this.#typed = text;
|
|
707
|
+
this.#echo = true;
|
|
708
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
709
|
+
this.#echo = false;
|
|
710
|
+
if (commit) input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
711
|
+
this.#showClear();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
#onInput(event?: InputEvent): void {
|
|
715
|
+
const input = this.input!;
|
|
716
|
+
// An IME composition is no search yet: `compositionend` filters (TASK-438, M21).
|
|
717
|
+
if (event?.isComposing) return;
|
|
718
|
+
// Browser autofill writes an unfocused field, or replaces the text: no popup, no filter; a
|
|
719
|
+
// text that names an option exactly chooses it (TASK-438, M20).
|
|
720
|
+
if (document.activeElement !== input || event?.inputType === "insertReplacementText") {
|
|
721
|
+
this.#autofill();
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (this.#free) {
|
|
725
|
+
if (this.#echo) return;
|
|
726
|
+
this.#typed = input.value;
|
|
727
|
+
this.#filter(input.value);
|
|
728
|
+
this.#highlight(undefined);
|
|
729
|
+
this.#showClear();
|
|
730
|
+
// An empty input closes the popup (the arrow keys open it with every option).
|
|
731
|
+
if (input.value !== "" && this.#showable()) {
|
|
732
|
+
this.#open();
|
|
733
|
+
this.#autoHighlight();
|
|
734
|
+
} else this.#close(false);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
// Clearing the text clears a single selection.
|
|
738
|
+
if (!this.multiple && input.value === "" && this.#values.length > 0) this.#set([], true);
|
|
739
|
+
this.#filter(input.value);
|
|
740
|
+
this.#highlight(undefined);
|
|
741
|
+
this.#showClear();
|
|
742
|
+
if (this.#showable()) this.#open();
|
|
743
|
+
else this.#close(false);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Text the browser autofilled: the option whose label or value it is, else nothing new. */
|
|
747
|
+
#autofill(): void {
|
|
748
|
+
const input = this.input!;
|
|
749
|
+
const text = fold(input.value.trim());
|
|
750
|
+
if (this.#free) {
|
|
751
|
+
this.#typed = input.value;
|
|
752
|
+
this.#showClear();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const option = this.#options().find(
|
|
756
|
+
(o) =>
|
|
757
|
+
!disabled(o) && (fold(optionLabel(o)) === text || fold(o.dataset.value ?? "") === text),
|
|
758
|
+
);
|
|
759
|
+
if (option) {
|
|
760
|
+
const value = option.dataset.value ?? "";
|
|
761
|
+
this.#set(this.multiple ? [...this.#values, value] : [value], true);
|
|
762
|
+
}
|
|
763
|
+
this.#close();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Options added or removed (async options): they take the selection and the current search. */
|
|
767
|
+
#onMutation(records: MutationRecord[]): void {
|
|
768
|
+
const listbox = this.listbox;
|
|
769
|
+
// Before the server's selection is read, the parser may still be adding the options.
|
|
770
|
+
if (!this.#defaults || !listbox || !records.some((r) => listbox.contains(r.target))) return;
|
|
771
|
+
this.#sync();
|
|
772
|
+
const input = this.input;
|
|
773
|
+
const label = this.#labels.get(this.#values[0] ?? "");
|
|
774
|
+
const search = this.#free ? this.#typed : input && input.value !== label ? input.value : "";
|
|
775
|
+
this.#filter(search);
|
|
776
|
+
const highlighted = this.#highlighted();
|
|
777
|
+
if (highlighted && (!highlighted.isConnected || highlighted.hidden)) this.#highlight(undefined);
|
|
778
|
+
if (this.open && !this.#showable()) this.#close(false);
|
|
779
|
+
else if (!this.open && search && document.activeElement === input && this.#showable()) {
|
|
780
|
+
this.#open();
|
|
781
|
+
}
|
|
782
|
+
if (this.open && search) this.#autoHighlight();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** With `auto-highlight` (free): the first match, while the text is not empty. */
|
|
786
|
+
#autoHighlight(): void {
|
|
787
|
+
if (!this.#free || !this.hasAttribute("auto-highlight") || this.#highlighted()) return;
|
|
788
|
+
this.#pointed = false;
|
|
789
|
+
this.#highlight(this.#reachable()[0]);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* The chips' keys (`multiple`, after Base UI, TASK-461): the arrows move between the chips and,
|
|
794
|
+
* past the last, back to the input; Backspace and Delete remove the focused chip's value, and
|
|
795
|
+
* focus goes to the chip before it, else the one in its place, else the input.
|
|
796
|
+
*/
|
|
797
|
+
#onChipKey(event: KeyboardEvent): boolean {
|
|
798
|
+
const list = this.#part("chips");
|
|
799
|
+
const chip = (event.target as Element).closest?.<HTMLElement>("[role=listitem]");
|
|
800
|
+
if (!list || !chip || !list.contains(chip) || event.defaultPrevented) return false;
|
|
801
|
+
const chips = () => [...list.querySelectorAll<HTMLElement>(":scope > [role=listitem]")];
|
|
802
|
+
const at = chips().indexOf(chip);
|
|
803
|
+
const rtl = getComputedStyle(list).direction === "rtl";
|
|
804
|
+
if (event.key === (rtl ? "ArrowRight" : "ArrowLeft")) {
|
|
805
|
+
chips()[Math.max(at - 1, 0)]?.focus();
|
|
806
|
+
} else if (event.key === (rtl ? "ArrowLeft" : "ArrowRight")) {
|
|
807
|
+
(chips()[at + 1] ?? this.input)?.focus();
|
|
808
|
+
} else if (event.key === "Backspace" || event.key === "Delete") {
|
|
809
|
+
this.#remove(chip.dataset.value!);
|
|
810
|
+
const rest = chips();
|
|
811
|
+
(rest[at - 1] ?? rest[at] ?? this.input)?.focus();
|
|
812
|
+
} else return false;
|
|
813
|
+
event.preventDefault();
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
#onKeyDown(event: KeyboardEvent): void {
|
|
818
|
+
if (this.#onChipKey(event)) return;
|
|
819
|
+
const input = this.input;
|
|
820
|
+
if (!input || event.target !== input || event.isComposing || event.defaultPrevented) return;
|
|
821
|
+
const open = this.open;
|
|
822
|
+
switch (event.key) {
|
|
823
|
+
case "ArrowDown":
|
|
824
|
+
case "ArrowUp": {
|
|
825
|
+
event.preventDefault();
|
|
826
|
+
const down = event.key === "ArrowDown";
|
|
827
|
+
if (event.altKey) {
|
|
828
|
+
// Alt+Arrow Down opens without moving the highlight, Alt+Arrow Up closes.
|
|
829
|
+
if (down) this.#show(false);
|
|
830
|
+
else this.#close();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (!open) {
|
|
834
|
+
this.#show(true, down ? "first" : "last");
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// From the last option the highlight goes back to the input, then around, as in Base UI.
|
|
838
|
+
const reachable = this.#reachable();
|
|
839
|
+
const index = reachable.indexOf(this.#highlighted()!);
|
|
840
|
+
const next = index === -1 ? (down ? 0 : reachable.length - 1) : index + (down ? 1 : -1);
|
|
841
|
+
this.#pointed = false;
|
|
842
|
+
this.#highlight(reachable[next]);
|
|
843
|
+
if (this.#free && this.hasAttribute("inline")) this.#inline(reachable[next]);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
case "Enter": {
|
|
847
|
+
// Safari's Enter that commits an IME composition comes after `compositionend`, as key 229:
|
|
848
|
+
// it chooses nothing (TASK-438, M21).
|
|
849
|
+
if (event.keyCode === 229) return;
|
|
850
|
+
const option = this.#highlighted();
|
|
851
|
+
if (open && option) {
|
|
852
|
+
event.preventDefault();
|
|
853
|
+
this.#choose(option);
|
|
854
|
+
// As a click on it does (TASK-454, L32).
|
|
855
|
+
if (this.hasAttribute("submit-on-item-click") && !disabled(option)) {
|
|
856
|
+
if (input.form) submit(input.form);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
case "Escape":
|
|
862
|
+
if (open) {
|
|
863
|
+
event.preventDefault();
|
|
864
|
+
// An inline completion goes back to the typed text.
|
|
865
|
+
if (this.#free) input.value = this.#typed;
|
|
866
|
+
this.#close();
|
|
867
|
+
} else if (input.value !== "" || this.#values.length > 0) {
|
|
868
|
+
// Closed with a value: Escape clears the text and the selection, as Base UI's, and is
|
|
869
|
+
// consumed, so a parent dialog stays open (user, TASK-465). Empty, the key goes on.
|
|
870
|
+
event.preventDefault();
|
|
871
|
+
this.#clear();
|
|
872
|
+
}
|
|
873
|
+
return;
|
|
874
|
+
case "Tab":
|
|
875
|
+
this.#close();
|
|
876
|
+
return;
|
|
877
|
+
case "ArrowLeft":
|
|
878
|
+
case "ArrowRight": {
|
|
879
|
+
// At the start of the input, the arrow toward the chips moves into them (TASK-461).
|
|
880
|
+
const back = getComputedStyle(input).direction === "rtl" ? "ArrowRight" : "ArrowLeft";
|
|
881
|
+
const last = this.#part("chips")?.querySelector<HTMLElement>(
|
|
882
|
+
":scope > [role=listitem]:last-child",
|
|
883
|
+
);
|
|
884
|
+
if (event.key !== back || event.shiftKey || !last) return;
|
|
885
|
+
if (input.selectionStart !== 0 || input.selectionEnd !== 0) return;
|
|
886
|
+
event.preventDefault();
|
|
887
|
+
last.focus();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
case "Backspace":
|
|
891
|
+
// With `multiple`, Backspace in an empty input removes the last value.
|
|
892
|
+
if (this.multiple && input.value === "" && this.#values.length > 0) {
|
|
893
|
+
event.preventDefault();
|
|
894
|
+
this.#remove(this.#values.at(-1)!);
|
|
895
|
+
}
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
#onClick(event: MouseEvent): void {
|
|
901
|
+
const target = event.target as Element;
|
|
902
|
+
const option = target.closest<HTMLElement>(OPTION);
|
|
903
|
+
if (option && this.listbox?.contains(option)) {
|
|
904
|
+
this.#choose(option);
|
|
905
|
+
if (this.hasAttribute("submit-on-item-click") && !disabled(option)) {
|
|
906
|
+
if (this.input?.form) submit(this.input.form);
|
|
907
|
+
}
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const chip = target.closest<HTMLButtonElement>("button");
|
|
911
|
+
if (chip && this.#part("chips")?.contains(chip)) {
|
|
912
|
+
this.#remove(chip.value);
|
|
913
|
+
this.input?.focus();
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
// A click on the input opens the popup, as in Base UI (an Autocomplete's with
|
|
917
|
+
// `open-on-input-click`).
|
|
918
|
+
if (target === this.input) {
|
|
919
|
+
const opens = !this.#free || this.hasAttribute("open-on-input-click");
|
|
920
|
+
if (!this.open && event.button === 0 && opens) this.#show(!this.#free);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
// Invoker commands where the browser lacks them.
|
|
924
|
+
const invoker = target.closest<HTMLButtonElement>("button[command^='--']");
|
|
925
|
+
if (!invokers && invoker && this.contains(invoker)) {
|
|
926
|
+
this.#onCommand(invoker.getAttribute("command")!);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
#onCommand(command: string, source: Element | null = null): void {
|
|
931
|
+
if (this.#disabled()) return;
|
|
932
|
+
if (command === "--toggle") {
|
|
933
|
+
// A press on an invoker outside the element closed the popup (focus left it, or an outside
|
|
934
|
+
// press): its command, a moment later, is that same close, not a reopening (TASK-454, L32).
|
|
935
|
+
const outside = source !== null && !this.contains(source);
|
|
936
|
+
if (outside && !this.open && performance.now() - this.#dismissed < 400) return;
|
|
937
|
+
if (this.open) this.#close();
|
|
938
|
+
else this.#show(true);
|
|
939
|
+
this.input?.focus();
|
|
940
|
+
} else if (command === "--clear") {
|
|
941
|
+
this.#clear();
|
|
942
|
+
this.input?.focus();
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Empties the text and the selection, as the user would: `input` and `change` (Clear, Escape). */
|
|
947
|
+
#clear(): void {
|
|
948
|
+
if (this.#free) this.#complete("", true);
|
|
949
|
+
else if (this.input) this.input.value = "";
|
|
950
|
+
this.#filter("");
|
|
951
|
+
this.#set([], true);
|
|
952
|
+
this.#close(false);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
#onPress(event: PointerEvent): void {
|
|
956
|
+
const target = event.target as Element;
|
|
957
|
+
// Presses in the popup and on the buttons keep focus in the input.
|
|
958
|
+
if (target !== this.input && (this.popup?.contains(target) || target.closest("button"))) {
|
|
959
|
+
event.preventDefault();
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
#onPointerMove(event: PointerEvent): void {
|
|
964
|
+
// Keyboard scrolling under a resting pointer moves nothing (../layer/layer.ts).
|
|
965
|
+
if (!moved(this, event)) return;
|
|
966
|
+
const option = (event.target as Element).closest?.<HTMLElement>(OPTION);
|
|
967
|
+
if (option && this.listbox?.contains(option) && !disabled(option) && !option.hidden) {
|
|
968
|
+
this.#pointed = true;
|
|
969
|
+
this.#highlight(option);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (!customElements.get("lu-combobox")) customElements.define("lu-combobox", ComboboxElement);
|