@oscarpalmer/oui 0.29.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/css/badge.css +4 -4
- package/dist/css/combobox.css +165 -0
- package/dist/css/form.css +18 -14
- package/dist/js/combobox.d.mts +91 -0
- package/dist/js/combobox.mjs +797 -0
- package/dist/js/dialog.d.mts +4 -3
- package/dist/js/dialog.mjs +1 -1
- package/dist/js/{draggable-fIYXd8eO.d.mts → draggable-a6w0J1OD.d.mts} +1 -2
- package/dist/js/floatable/floatable.embedded.d.mts +1 -1
- package/dist/js/floatable/floatable.embedded.mjs +2 -2
- package/dist/js/floatable/floatable.standalone.d.mts +1 -1
- package/dist/js/{floatable.embedded-DUR8bQBK.d.mts → floatable.embedded-Bct4U5Dm.d.mts} +0 -1
- package/dist/js/focus-trap/focus-trap.embedded.mjs +13 -9
- package/dist/js/index.d.mts +9 -4
- package/dist/js/index.mjs +2 -1
- package/dist/js/internal/draggable.d.mts +1 -1
- package/dist/js/internal/draggable.mjs +22 -21
- package/dist/js/internal/navigable.d.mts +0 -1
- package/dist/js/internal/navigable.mjs +4 -3
- package/dist/js/menu.mjs +5 -2
- package/dist/js/movable/movable.embedded.d.mts +1 -1
- package/dist/js/movable/movable.embedded.mjs +7 -12
- package/dist/js/movable/movable.standalone.d.mts +1 -1
- package/dist/js/popover.d.mts +1 -2
- package/dist/js/popover.mjs +1 -1
- package/dist/js/resizable/resizable.embedded.d.mts +1 -1
- package/dist/js/resizable/resizable.embedded.mjs +41 -20
- package/dist/js/resizable/resizable.standalone.d.mts +1 -1
- package/dist/js/{resizable.embedded-CXrUVPzR.d.mts → resizable.embedded-3qoR0Bjm.d.mts} +0 -1
- package/dist/js/sortable/sortable.embedded.d.mts +1 -1
- package/dist/js/sortable/sortable.embedded.mjs +28 -37
- package/dist/js/sortable/sortable.standalone.d.mts +1 -1
- package/dist/js/splitter.d.mts +2 -2
- package/dist/js/splitter.mjs +11 -11
- package/dist/js/tooltip.d.mts +1 -2
- package/dist/js/tooltip.mjs +3 -3
- package/package.json +7 -3
- package/src/css/badge.scss +1 -18
- package/src/css/combobox.scss +137 -0
- package/src/css/form.scss +18 -80
- package/src/css/mixins.scss +86 -0
- package/src/js/combobox.ts +1479 -0
- package/src/js/focus-trap/focus-trap.embedded.ts +19 -11
- package/src/js/index.ts +1 -0
- package/src/js/internal/draggable.ts +33 -31
- package/src/js/internal/navigable.ts +5 -1
- package/src/js/menu.ts +5 -2
- package/src/js/movable/movable.embedded.ts +3 -8
- package/src/js/resizable/resizable.embedded.ts +57 -20
- package/src/js/sortable/sortable.embedded.ts +21 -26
- package/src/js/splitter.ts +37 -23
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { createEmbeddedFloatable } from "./floatable/floatable.embedded.mjs";
|
|
2
|
+
import { clamp } from "@oscarpalmer/atoms/number";
|
|
3
|
+
import { getString, join } from "@oscarpalmer/atoms/string";
|
|
4
|
+
import { getAria, setAria, setRole } from "@oscarpalmer/toretto/aria";
|
|
5
|
+
import { getAttribute, setAttribute } from "@oscarpalmer/toretto/attribute";
|
|
6
|
+
import { createElement } from "@oscarpalmer/toretto/create";
|
|
7
|
+
import { dispatch, on } from "@oscarpalmer/toretto/event";
|
|
8
|
+
import { findAncestor } from "@oscarpalmer/toretto/find";
|
|
9
|
+
import { setProperty } from "@oscarpalmer/toretto/property";
|
|
10
|
+
import { setStyle } from "@oscarpalmer/toretto/style";
|
|
11
|
+
//#region src/js/combobox.ts
|
|
12
|
+
var OuiComboboxElement = class extends HTMLElement {
|
|
13
|
+
static formAssociated = true;
|
|
14
|
+
static observedAttributes = [
|
|
15
|
+
"disabled",
|
|
16
|
+
"max",
|
|
17
|
+
"min",
|
|
18
|
+
"readonly",
|
|
19
|
+
"required",
|
|
20
|
+
"value"
|
|
21
|
+
];
|
|
22
|
+
#internals;
|
|
23
|
+
#state;
|
|
24
|
+
/**
|
|
25
|
+
* Is the combobox disabled?
|
|
26
|
+
*/
|
|
27
|
+
get disabled() {
|
|
28
|
+
return this.hasAttribute("disabled");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Should the combobox be disabled?
|
|
32
|
+
*/
|
|
33
|
+
set disabled(value) {
|
|
34
|
+
if (typeof value === "boolean" && value !== this.#state.disabled) setDisabled(this, this.#state, value);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Maximum number of selected options
|
|
38
|
+
*/
|
|
39
|
+
get max() {
|
|
40
|
+
return this.#state.max;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Set the maximum number of selected options
|
|
44
|
+
*/
|
|
45
|
+
set max(value) {
|
|
46
|
+
setLimit("max", this, this.#state, value);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Minimum number of selected options
|
|
50
|
+
*/
|
|
51
|
+
get min() {
|
|
52
|
+
return this.#state.min;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Set the minimum number of selected options
|
|
56
|
+
*/
|
|
57
|
+
set min(value) {
|
|
58
|
+
setLimit("min", this, this.#state, value);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Does the combobox allow multiple values?
|
|
62
|
+
*/
|
|
63
|
+
get multiple() {
|
|
64
|
+
return this.hasAttribute("multiple");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Name of the combobox
|
|
68
|
+
*/
|
|
69
|
+
get name() {
|
|
70
|
+
return getAttribute(this, "name") ?? "";
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Is the combobox readonly?
|
|
74
|
+
*/
|
|
75
|
+
get readonly() {
|
|
76
|
+
return this.hasAttribute("readonly");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Should the combobox be readonly?
|
|
80
|
+
*/
|
|
81
|
+
set readonly(value) {
|
|
82
|
+
if (typeof value === "boolean" && value !== this.#state.readonly) setReadonly(this, this.#state, value);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Is the combobox required?
|
|
86
|
+
*/
|
|
87
|
+
get required() {
|
|
88
|
+
return this.hasAttribute("required");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Should the combobox be required?
|
|
92
|
+
*/
|
|
93
|
+
set required(value) {
|
|
94
|
+
if (typeof value === "boolean" && value !== this.#state.required) setRequired(this, this.#state, value);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Selected options
|
|
98
|
+
*/
|
|
99
|
+
get selected() {
|
|
100
|
+
return this.#state.selected.slice();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Type of combobox
|
|
104
|
+
*/
|
|
105
|
+
get type() {
|
|
106
|
+
return this.#state.type;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Current value
|
|
110
|
+
*/
|
|
111
|
+
get value() {
|
|
112
|
+
return this.#state.values[0];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Set the value
|
|
116
|
+
*/
|
|
117
|
+
set value(value) {
|
|
118
|
+
const [option, all] = getOption(this.#state, value);
|
|
119
|
+
if (option != null) setNext("set", this, this.#state, all, option);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Current values
|
|
123
|
+
*/
|
|
124
|
+
get values() {
|
|
125
|
+
return this.#state.values.slice();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Set the values
|
|
129
|
+
*/
|
|
130
|
+
set values(value) {
|
|
131
|
+
setValues(this, this.#state, value);
|
|
132
|
+
}
|
|
133
|
+
constructor() {
|
|
134
|
+
super();
|
|
135
|
+
this.#internals = this.attachInternals();
|
|
136
|
+
this.#state = getState(this, this.#internals);
|
|
137
|
+
comboboxes.set(this.#state.content, this);
|
|
138
|
+
states.set(this, this.#state);
|
|
139
|
+
states.set(this.#state.content, this.#state);
|
|
140
|
+
}
|
|
141
|
+
attributeChangedCallback(name, _, value) {
|
|
142
|
+
switch (name) {
|
|
143
|
+
case "disabled":
|
|
144
|
+
case "readonly":
|
|
145
|
+
case "required":
|
|
146
|
+
case "value":
|
|
147
|
+
this[name] = this.hasAttribute(name);
|
|
148
|
+
break;
|
|
149
|
+
case "max":
|
|
150
|
+
case "min":
|
|
151
|
+
setLimit(name, this, this.#state, value == null ? void 0 : Number.parseInt(value, 10));
|
|
152
|
+
break;
|
|
153
|
+
default: break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
clear() {
|
|
157
|
+
setValues(this, this.#state, []);
|
|
158
|
+
}
|
|
159
|
+
formResetCallback() {
|
|
160
|
+
this.#state.touched = false;
|
|
161
|
+
this.clear();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
function filterOptions(state, options, clear) {
|
|
165
|
+
if (clear || state.autocomplete.none) state.filter = "";
|
|
166
|
+
const filter = state.filter.toLocaleLowerCase();
|
|
167
|
+
const all = options ?? getOptions(state)[0];
|
|
168
|
+
const { length } = all;
|
|
169
|
+
const visible = [];
|
|
170
|
+
for (let index = 0; index < length; index += 1) {
|
|
171
|
+
const option = all[index];
|
|
172
|
+
option.hidden = option.textContent?.toLocaleLowerCase().includes(filter) === false;
|
|
173
|
+
if (!option.hidden) visible.push(option);
|
|
174
|
+
}
|
|
175
|
+
state.empty.hidden = visible.length > 0;
|
|
176
|
+
if (state.empty.hidden) return state.active != null && visible.includes(state.active) ? state.active : visible[0];
|
|
177
|
+
}
|
|
178
|
+
function generateIds(select, prefix) {
|
|
179
|
+
const options = [...select.options];
|
|
180
|
+
const { length } = options;
|
|
181
|
+
for (let index = 0; index < length; index += 1) {
|
|
182
|
+
const option = options[index];
|
|
183
|
+
if (option.id === "") option.id = `${prefix}_option_${index}`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function getAnchor(type, id, label, select, autocomplete, multiple) {
|
|
187
|
+
const anchor = type === "autocomplete" ? getInput(id, select) : getBox(id, select, multiple);
|
|
188
|
+
setAria(anchor, {
|
|
189
|
+
autocomplete: type === "autocomplete" ? autocomplete.value : void 0,
|
|
190
|
+
controls: `${id}_content`,
|
|
191
|
+
expanded: false,
|
|
192
|
+
haspopup: "listbox",
|
|
193
|
+
labelledby: label.id
|
|
194
|
+
});
|
|
195
|
+
setAttribute(anchor, ATTRIBUTE_ANCHOR, "");
|
|
196
|
+
setRole(anchor, "combobox");
|
|
197
|
+
return anchor;
|
|
198
|
+
}
|
|
199
|
+
function getAttributeValue(element, attribute, possible, defaultValue) {
|
|
200
|
+
const value = getAttribute(element, attribute);
|
|
201
|
+
return possible.has(value) ? value : defaultValue;
|
|
202
|
+
}
|
|
203
|
+
function getAutoComplete(element, select) {
|
|
204
|
+
let value;
|
|
205
|
+
if (select) value = "list";
|
|
206
|
+
else value = getAttributeValue(element, "autocomplete", AUTOCOMPLETES, "none");
|
|
207
|
+
return {
|
|
208
|
+
value,
|
|
209
|
+
both: value === "both",
|
|
210
|
+
inline: value === "inline",
|
|
211
|
+
list: value === "list",
|
|
212
|
+
none: value === "none"
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function getBox(id, select, multiple) {
|
|
216
|
+
const box = createElement("div", {
|
|
217
|
+
id,
|
|
218
|
+
tabIndex: 0,
|
|
219
|
+
textContent: multiple ? "" : select.selectedOptions[0]?.textContent ?? ""
|
|
220
|
+
});
|
|
221
|
+
if (multiple) {
|
|
222
|
+
const selection = createElement("ul", {}, { [ATTRIBUTE_SELECTION]: "" });
|
|
223
|
+
for (const option of select.selectedOptions) selection.append(getSelectionItem(option));
|
|
224
|
+
box.append(selection);
|
|
225
|
+
}
|
|
226
|
+
return box;
|
|
227
|
+
}
|
|
228
|
+
function getContent(id, label, select, autocomplete, multiple) {
|
|
229
|
+
const content = createElement("div", { id: `${id}_content` }, {
|
|
230
|
+
[ATTRIBUTE_CONTENT]: "",
|
|
231
|
+
popover: "",
|
|
232
|
+
tabindex: -1,
|
|
233
|
+
"aria-labelledby": label.id
|
|
234
|
+
});
|
|
235
|
+
const list = createElement("ul", { role: "listbox" }, {
|
|
236
|
+
[ATTRIBUTE_LIST]: "",
|
|
237
|
+
"aria-labelledby": label.id,
|
|
238
|
+
"aria-multiselectable": multiple
|
|
239
|
+
});
|
|
240
|
+
const options = [...select.options];
|
|
241
|
+
const selected = [];
|
|
242
|
+
for (let index = 0; index < options.length; index += 1) {
|
|
243
|
+
const option = options[index];
|
|
244
|
+
const item = createElement("li", {
|
|
245
|
+
id: option.id,
|
|
246
|
+
textContent: option.textContent
|
|
247
|
+
}, {
|
|
248
|
+
[ATTRIBUTE_OPTION]: "",
|
|
249
|
+
[ATTRIBUTE_OPTION_ACTIVE]: index === 0 ? "" : void 0,
|
|
250
|
+
role: "option",
|
|
251
|
+
value: option.value,
|
|
252
|
+
"aria-selected": !autocomplete.none && option.selected
|
|
253
|
+
});
|
|
254
|
+
if (option.selected) selected.push(item);
|
|
255
|
+
list.append(item);
|
|
256
|
+
}
|
|
257
|
+
const empty = createElement("div", {
|
|
258
|
+
hidden: true,
|
|
259
|
+
role: "status",
|
|
260
|
+
textContent: "No results found"
|
|
261
|
+
}, {
|
|
262
|
+
[ATTRIBUTE_EMPTY]: "",
|
|
263
|
+
"aria-live": "polite"
|
|
264
|
+
});
|
|
265
|
+
content.append(list, empty);
|
|
266
|
+
return [
|
|
267
|
+
content,
|
|
268
|
+
selected,
|
|
269
|
+
empty
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
function getFormValue(state, values) {
|
|
273
|
+
if (values.length === 0 || !state.multiple) return values[0];
|
|
274
|
+
let formValues = [];
|
|
275
|
+
for (const value of values) formValues.push(`${state.name}=${value}`);
|
|
276
|
+
return join(formValues, "&");
|
|
277
|
+
}
|
|
278
|
+
function getFormValues(state, added) {
|
|
279
|
+
let values;
|
|
280
|
+
if (state.multiple) values = typeof added === "boolean" ? state.selected.map((option) => getAttribute(option, "value") ?? "") : state.values;
|
|
281
|
+
else values = [getAttribute(state.selected[0], "value") ?? ""];
|
|
282
|
+
const previous = getFormValue(state, state.values);
|
|
283
|
+
const next = getFormValue(state, values);
|
|
284
|
+
return [values, next !== previous];
|
|
285
|
+
}
|
|
286
|
+
function getId() {
|
|
287
|
+
index += 1;
|
|
288
|
+
return `oui_combobox_${index}`;
|
|
289
|
+
}
|
|
290
|
+
function getInput(id, select) {
|
|
291
|
+
return createElement("input", {
|
|
292
|
+
id,
|
|
293
|
+
value: select.selectedOptions[0]?.textContent ?? ""
|
|
294
|
+
}, {
|
|
295
|
+
[ATTRIBUTE_ANCHOR]: "",
|
|
296
|
+
autocomplete: "off",
|
|
297
|
+
type: "text"
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
function getOption(state, value) {
|
|
301
|
+
const [all] = getOptions(state);
|
|
302
|
+
const valueAsString = getString(value).toLocaleLowerCase();
|
|
303
|
+
filterOptions(state, all, true);
|
|
304
|
+
let match;
|
|
305
|
+
for (const option of all) if (getAttribute(option, "value")?.toLocaleLowerCase() === valueAsString) {
|
|
306
|
+
match = option;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
return [match, all];
|
|
310
|
+
}
|
|
311
|
+
function getOptions(state) {
|
|
312
|
+
const all = [...state.content.querySelectorAll(`[${ATTRIBUTE_OPTION}]`)].filter((option) => option instanceof HTMLElement);
|
|
313
|
+
return [all, all.filter((option) => !option.hidden)];
|
|
314
|
+
}
|
|
315
|
+
function getSelected(update, state, element, next) {
|
|
316
|
+
if (state.autocomplete.none) return false;
|
|
317
|
+
const selected = getAria(element, "selected") === "true";
|
|
318
|
+
if (update === "navigation") {
|
|
319
|
+
if (state.autocomplete.both) return element === next;
|
|
320
|
+
return selected;
|
|
321
|
+
}
|
|
322
|
+
if (update === "none") return selected;
|
|
323
|
+
if (update === "set") {
|
|
324
|
+
if (next == null) return selected;
|
|
325
|
+
if (state.multiple) return element === next ? !selected : selected;
|
|
326
|
+
return element === next;
|
|
327
|
+
}
|
|
328
|
+
return selected;
|
|
329
|
+
}
|
|
330
|
+
function getSelectionItem(option) {
|
|
331
|
+
const icon = createElement("span", { innerHTML: "×" }, { "aria-hidden": true });
|
|
332
|
+
const label = createElement("span", { textContent: option.textContent });
|
|
333
|
+
const item = createElement("li", {}, {
|
|
334
|
+
[ATTRIBUTE_SELECTION_ITEM]: "",
|
|
335
|
+
option: option.id
|
|
336
|
+
});
|
|
337
|
+
item.append(icon, label);
|
|
338
|
+
return item;
|
|
339
|
+
}
|
|
340
|
+
function getState(element, internals) {
|
|
341
|
+
const select = element.querySelector("select");
|
|
342
|
+
if (select == null || select.id === "") throw new Error(MESSAGE_SELECT);
|
|
343
|
+
const label = element.querySelector(`label[for="${select.id}"]`);
|
|
344
|
+
if (label == null) throw new Error(MESSAGE_LABEL);
|
|
345
|
+
select.hidden = true;
|
|
346
|
+
element.id = select.id;
|
|
347
|
+
setAttribute(element, "name", select.name === "" ? select.id : select.name);
|
|
348
|
+
const disabled = element.disabled || select.disabled;
|
|
349
|
+
const multiple = element.multiple || select.multiple;
|
|
350
|
+
const readonly = element.readonly;
|
|
351
|
+
const required = element.required || select.required;
|
|
352
|
+
const id = getId();
|
|
353
|
+
const type = multiple ? "select" : getAttributeValue(element, "type", TYPES, "select");
|
|
354
|
+
const autocomplete = getAutoComplete(element, type === "select");
|
|
355
|
+
label.htmlFor = id;
|
|
356
|
+
label.id = `${id}_label`;
|
|
357
|
+
generateIds(select, id);
|
|
358
|
+
const anchor = getAnchor(type, id, label, select, autocomplete, multiple);
|
|
359
|
+
const [content, selected, empty] = getContent(id, label, select, autocomplete, multiple);
|
|
360
|
+
const values = selected.length === 0 ? [""] : selected.map((option) => getAttribute(option, "value") ?? "");
|
|
361
|
+
const floatable = createEmbeddedFloatable(anchor, content, {
|
|
362
|
+
position: "below-start",
|
|
363
|
+
reusable: false
|
|
364
|
+
});
|
|
365
|
+
floatable.update();
|
|
366
|
+
select.insertAdjacentElement("afterend", anchor);
|
|
367
|
+
anchor.insertAdjacentElement("afterend", content);
|
|
368
|
+
select.remove();
|
|
369
|
+
const state = {
|
|
370
|
+
anchor,
|
|
371
|
+
autocomplete,
|
|
372
|
+
content,
|
|
373
|
+
disabled,
|
|
374
|
+
empty,
|
|
375
|
+
floatable,
|
|
376
|
+
id,
|
|
377
|
+
internals,
|
|
378
|
+
label,
|
|
379
|
+
multiple,
|
|
380
|
+
readonly,
|
|
381
|
+
required,
|
|
382
|
+
select,
|
|
383
|
+
selected,
|
|
384
|
+
type,
|
|
385
|
+
values,
|
|
386
|
+
filter: "",
|
|
387
|
+
listeners: [],
|
|
388
|
+
name: element.name,
|
|
389
|
+
touched: false
|
|
390
|
+
};
|
|
391
|
+
state.listeners.push(on(anchor, "blur", () => {
|
|
392
|
+
state.touched = true;
|
|
393
|
+
updateValidity(state);
|
|
394
|
+
}), on(content, "click", (event) => {
|
|
395
|
+
onContent(event, content);
|
|
396
|
+
}), on(content, "toggle", () => {
|
|
397
|
+
setAria(anchor, {
|
|
398
|
+
activedescendant: floatable.open ? state.active?.id : void 0,
|
|
399
|
+
expanded: floatable.open
|
|
400
|
+
});
|
|
401
|
+
if (floatable.open) updateWidth(state);
|
|
402
|
+
}));
|
|
403
|
+
setDisabled(element, state, disabled);
|
|
404
|
+
return state;
|
|
405
|
+
}
|
|
406
|
+
function hasTextSelection(element) {
|
|
407
|
+
return element instanceof HTMLInputElement && element.selectionStart !== element.selectionEnd;
|
|
408
|
+
}
|
|
409
|
+
function onAnchor(event, handler) {
|
|
410
|
+
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
411
|
+
const combobox = findAncestor(event, TAGNAME);
|
|
412
|
+
const state = states.get(combobox);
|
|
413
|
+
if (combobox == null || state == null) return;
|
|
414
|
+
if (findAncestor(event, SELECTOR_ANCHOR) != null) handler(event, combobox, state);
|
|
415
|
+
}
|
|
416
|
+
function onAnchorKeydown(event, combobox, state) {
|
|
417
|
+
const isSelect = state.type === "select";
|
|
418
|
+
let preventDefault = true;
|
|
419
|
+
switch (true) {
|
|
420
|
+
case event.key === " " && state.type === "select":
|
|
421
|
+
case event.key === "Enter":
|
|
422
|
+
onEnter(combobox, state);
|
|
423
|
+
break;
|
|
424
|
+
case event.key === "Escape":
|
|
425
|
+
onEscape(state);
|
|
426
|
+
break;
|
|
427
|
+
case isSelect && KEYS_ABSOLUTE.has(event.key):
|
|
428
|
+
onNavigation(event, combobox, state);
|
|
429
|
+
break;
|
|
430
|
+
case !isSelect && KEYS_SELECTION.has(event.key):
|
|
431
|
+
preventDefault = false;
|
|
432
|
+
if (hasTextSelection(state.anchor)) onSelection(combobox, state);
|
|
433
|
+
break;
|
|
434
|
+
case KEYS_NAVIGATION.has(event.key):
|
|
435
|
+
onNavigation(event, combobox, state);
|
|
436
|
+
break;
|
|
437
|
+
default:
|
|
438
|
+
preventDefault = false;
|
|
439
|
+
if (event.key === "Tab") onTab(combobox, state);
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
if (preventDefault) {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
event.stopPropagation();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function onAnchorKeyup(event, combobox, state) {
|
|
448
|
+
const typed = event.key.length === 1 && EXPRESSION_TYPED.test(event.key);
|
|
449
|
+
if (state.type === "select") {
|
|
450
|
+
if (typed) {
|
|
451
|
+
event.preventDefault();
|
|
452
|
+
event.stopPropagation();
|
|
453
|
+
onScrollTo(event, combobox, state);
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
let preventDefault = true;
|
|
458
|
+
if (typed) state.filter += event.key;
|
|
459
|
+
if (state.anchor instanceof HTMLInputElement && (KEYS_REMOVE.has(event.key) || state.anchor.value.length < state.filter.length)) {
|
|
460
|
+
state.filter = state.anchor.value;
|
|
461
|
+
filterOptions(state);
|
|
462
|
+
}
|
|
463
|
+
switch (true) {
|
|
464
|
+
case typed:
|
|
465
|
+
onTyped(combobox, state);
|
|
466
|
+
break;
|
|
467
|
+
case KEYS_REMOVE.has(event.key):
|
|
468
|
+
onRemove(combobox, state);
|
|
469
|
+
break;
|
|
470
|
+
default:
|
|
471
|
+
preventDefault = false;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
if (preventDefault) {
|
|
475
|
+
event.preventDefault();
|
|
476
|
+
event.stopPropagation();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function onClick(event) {
|
|
480
|
+
const combobox = findAncestor(event, TAGNAME);
|
|
481
|
+
const state = states.get(combobox);
|
|
482
|
+
if (combobox == null || state == null) return;
|
|
483
|
+
const label = findAncestor(event, "label");
|
|
484
|
+
if (label != null) {
|
|
485
|
+
if (label === state.label && combobox.type === "select") {
|
|
486
|
+
state.anchor.focus();
|
|
487
|
+
state.floatable.show();
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const selection = findAncestor(event, `[${ATTRIBUTE_SELECTION_ITEM}]`);
|
|
492
|
+
if (selection != null) {
|
|
493
|
+
setValue("set", combobox, state, state.selected.find((option) => option.id === getAttribute(selection, "option")));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (findAncestor(event, SELECTOR_ANCHOR) != null) {
|
|
497
|
+
state.floatable.show();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function onContent(event, content) {
|
|
502
|
+
const combobox = comboboxes.get(content);
|
|
503
|
+
const state = states.get(content);
|
|
504
|
+
if (combobox == null || state == null) return;
|
|
505
|
+
const option = findAncestor(event, SELECTOR_OPTION);
|
|
506
|
+
if (option instanceof HTMLElement) setNext("set", combobox, state, getOptions(state)[0], option);
|
|
507
|
+
state.anchor.focus();
|
|
508
|
+
}
|
|
509
|
+
function onEnter(combobox, state) {
|
|
510
|
+
if (hasTextSelection(state.anchor)) {
|
|
511
|
+
onSelection(combobox, state);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (!state.floatable.open) {
|
|
515
|
+
state.floatable.show();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
setNext("set", combobox, state, getOptions(state)[0], state.active);
|
|
519
|
+
}
|
|
520
|
+
function onEscape(state) {
|
|
521
|
+
state.floatable.hide();
|
|
522
|
+
}
|
|
523
|
+
function onKeydown(event) {
|
|
524
|
+
onAnchor(event, onAnchorKeydown);
|
|
525
|
+
}
|
|
526
|
+
function onKeyup(event) {
|
|
527
|
+
onAnchor(event, onAnchorKeyup);
|
|
528
|
+
}
|
|
529
|
+
function onNavigation(event, combobox, state) {
|
|
530
|
+
let skip = false;
|
|
531
|
+
if (!state.floatable.open) {
|
|
532
|
+
skip = true;
|
|
533
|
+
state.floatable.show();
|
|
534
|
+
}
|
|
535
|
+
const [all, visible] = getOptions(state);
|
|
536
|
+
if (visible.length === 0) return;
|
|
537
|
+
let next;
|
|
538
|
+
if (skip || state.active == null) next = state.active ?? visible.at(event.key === "ArrowDown" ? 0 : -1);
|
|
539
|
+
else {
|
|
540
|
+
const current = visible.findIndex((option) => option.id === state.active?.id);
|
|
541
|
+
let index;
|
|
542
|
+
switch (event.key) {
|
|
543
|
+
case "ArrowDown":
|
|
544
|
+
case "ArrowUp":
|
|
545
|
+
index = clamp(current + (event.key === "ArrowDown" ? 1 : -1), 0, visible.length - 1, true);
|
|
546
|
+
break;
|
|
547
|
+
case "End":
|
|
548
|
+
index = visible.length - 1;
|
|
549
|
+
break;
|
|
550
|
+
case "Home":
|
|
551
|
+
index = 0;
|
|
552
|
+
break;
|
|
553
|
+
case "PageDown":
|
|
554
|
+
case "PageUp":
|
|
555
|
+
index = clamp(current + (event.key === "PageDown" ? 10 : -10), 0, visible.length - 1);
|
|
556
|
+
break;
|
|
557
|
+
default: break;
|
|
558
|
+
}
|
|
559
|
+
if (index != null) next = visible[index];
|
|
560
|
+
}
|
|
561
|
+
setNext(state.autocomplete.none ? "none" : "navigation", combobox, state, all, next);
|
|
562
|
+
}
|
|
563
|
+
function onRemove(combobox, state) {
|
|
564
|
+
const [all] = getOptions(state);
|
|
565
|
+
setNext("remove", combobox, state, all, filterOptions(state, all));
|
|
566
|
+
}
|
|
567
|
+
function onScrollTo(event, combobox, state) {
|
|
568
|
+
const [all] = getOptions(state);
|
|
569
|
+
const key = event.key.toLocaleLowerCase();
|
|
570
|
+
const next = all.find((option) => option.textContent?.toLocaleLowerCase().startsWith(key) === true) ?? all.find((option) => option.textContent?.toLocaleLowerCase().includes(key) === true);
|
|
571
|
+
if (next != null) setNext("navigation", combobox, state, all, next);
|
|
572
|
+
}
|
|
573
|
+
function onSelection(combobox, state) {
|
|
574
|
+
state.filter = state.anchor instanceof HTMLInputElement ? state.anchor.value : "";
|
|
575
|
+
filterOptions(state);
|
|
576
|
+
setNext("set", combobox, state, getOptions(state)[0], state.active);
|
|
577
|
+
}
|
|
578
|
+
function onTab(combobox, state) {
|
|
579
|
+
if (combobox !== document.activeElement || !combobox.contains(document.activeElement)) state.floatable.hide();
|
|
580
|
+
}
|
|
581
|
+
function onTyped(combobox, state) {
|
|
582
|
+
if (!state.autocomplete.inline && state.anchor instanceof HTMLInputElement && state.anchor.value.length > 0) state.floatable.show();
|
|
583
|
+
const options = getOptions(state)[0];
|
|
584
|
+
setNext("typed", combobox, state, options, state.autocomplete.none ? state.active ?? options[0] : filterOptions(state));
|
|
585
|
+
}
|
|
586
|
+
function setDisabled(combobox, state, value) {
|
|
587
|
+
state.disabled = value;
|
|
588
|
+
setAria(state.anchor, "disabled", value);
|
|
589
|
+
setProperty(combobox, "disabled", value);
|
|
590
|
+
if (state.anchor instanceof HTMLInputElement) state.anchor.readOnly = value;
|
|
591
|
+
}
|
|
592
|
+
function setLimit(type, combobox, state, value) {
|
|
593
|
+
if (typeof value === "number" && !Number.isNaN(value) ? value !== state[type] : value == null) {
|
|
594
|
+
state[type] = value;
|
|
595
|
+
setAttribute(combobox, type, value);
|
|
596
|
+
updateValidity(state);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function setNext(update, combobox, state, all, next) {
|
|
600
|
+
for (const option of all) setAttribute(option, ATTRIBUTE_OPTION_ACTIVE, option === next ? "" : void 0);
|
|
601
|
+
state.active = next;
|
|
602
|
+
setAria(state.anchor, "activedescendant", next?.id);
|
|
603
|
+
next?.scrollIntoView(scrollOptions);
|
|
604
|
+
setValue(update, combobox, state, next);
|
|
605
|
+
}
|
|
606
|
+
function setReadonly(combobox, state, value) {
|
|
607
|
+
state.readonly = value;
|
|
608
|
+
setAria(state.anchor, "readonly", value);
|
|
609
|
+
setProperty(combobox, "readonly", value);
|
|
610
|
+
if (state.anchor instanceof HTMLInputElement) state.anchor.readOnly = value;
|
|
611
|
+
}
|
|
612
|
+
function setRequired(combobox, state, value) {
|
|
613
|
+
state.required = value;
|
|
614
|
+
setAria(state.anchor, "required", value);
|
|
615
|
+
setProperty(combobox, "required", value);
|
|
616
|
+
updateValidity(state);
|
|
617
|
+
}
|
|
618
|
+
function setValue(update, combobox, state, next) {
|
|
619
|
+
if (state.disabled || state.readonly || state.type === "select" && update !== "set") return;
|
|
620
|
+
let added;
|
|
621
|
+
let shouldUpdate;
|
|
622
|
+
let values;
|
|
623
|
+
if (update === "set" || !(state.anchor instanceof HTMLInputElement)) {
|
|
624
|
+
added = updateSelected(update, state, next);
|
|
625
|
+
[values, shouldUpdate] = getFormValues(state, added);
|
|
626
|
+
} else {
|
|
627
|
+
const [all] = getOptions(state);
|
|
628
|
+
for (const option of all) setAria(option, "selected", false);
|
|
629
|
+
const input = state.anchor.value;
|
|
630
|
+
const content = next?.textContent ?? "";
|
|
631
|
+
const value = next == null ? "" : getAttribute(next, "value") ?? "";
|
|
632
|
+
let selected;
|
|
633
|
+
if (content.toLocaleLowerCase() === input.toLocaleLowerCase()) {
|
|
634
|
+
values = [value];
|
|
635
|
+
if (next != null) {
|
|
636
|
+
selected = next;
|
|
637
|
+
setAria(next, "selected", true);
|
|
638
|
+
}
|
|
639
|
+
} else values = [state.autocomplete.none ? input : ""];
|
|
640
|
+
state.selected = selected == null ? [] : [selected];
|
|
641
|
+
shouldUpdate = state.values[0] !== values[0];
|
|
642
|
+
}
|
|
643
|
+
if (shouldUpdate) updateForm(combobox, state, values);
|
|
644
|
+
if (next == null) return;
|
|
645
|
+
if (state.type === "select") updateSelect(update, state, next, added);
|
|
646
|
+
else if (update !== "remove") updateAutoComplete(update, state, next);
|
|
647
|
+
if (update === "set" && !state.multiple) state.floatable.hide();
|
|
648
|
+
setTimeout(() => {
|
|
649
|
+
updateWidth(state);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
function setValues(combobox, state, value) {
|
|
653
|
+
if (!Array.isArray(value)) return;
|
|
654
|
+
const strings = (state.multiple ? value : [value]).map((value) => getString(value).toLocaleLowerCase());
|
|
655
|
+
const [all] = getOptions(state);
|
|
656
|
+
const selected = [];
|
|
657
|
+
const values = [];
|
|
658
|
+
let next;
|
|
659
|
+
for (const option of all) {
|
|
660
|
+
const value = getAttribute(option, "value");
|
|
661
|
+
if (value == null || !strings.includes(value.toLocaleLowerCase())) continue;
|
|
662
|
+
selected.push(option);
|
|
663
|
+
values.push(value);
|
|
664
|
+
next ??= option;
|
|
665
|
+
}
|
|
666
|
+
for (const option of state.selected) {
|
|
667
|
+
setAria(option, "selected", false);
|
|
668
|
+
state.anchor.querySelector(`[${ATTRIBUTE_SELECTION_ITEM}][option="${option.id}"]`)?.remove();
|
|
669
|
+
}
|
|
670
|
+
for (const option of selected) {
|
|
671
|
+
setAria(option, "selected", true);
|
|
672
|
+
state.anchor.querySelector(`[${ATTRIBUTE_SELECTION}]`)?.append(getSelectionItem(option));
|
|
673
|
+
}
|
|
674
|
+
state.selected = selected.slice();
|
|
675
|
+
setNext("navigation", combobox, state, all, next);
|
|
676
|
+
updateForm(combobox, state, values);
|
|
677
|
+
}
|
|
678
|
+
function updateAutoComplete(update, state, next) {
|
|
679
|
+
if (!(update === "set" || state.autocomplete.both || state.autocomplete.inline)) return;
|
|
680
|
+
const content = next.textContent ?? "";
|
|
681
|
+
if (update === "typed" && !content.toLocaleLowerCase().startsWith(state.filter.toLocaleLowerCase())) return;
|
|
682
|
+
const { length } = content;
|
|
683
|
+
if (state.anchor instanceof HTMLInputElement) {
|
|
684
|
+
state.anchor.value = content;
|
|
685
|
+
state.anchor.setSelectionRange(update === "typed" ? state.filter.length : length, length);
|
|
686
|
+
} else state.anchor.textContent = content;
|
|
687
|
+
}
|
|
688
|
+
function updateForm(combobox, state, values) {
|
|
689
|
+
const formValues = values.length === 0 ? [""] : values;
|
|
690
|
+
const formData = new FormData();
|
|
691
|
+
for (const value of formValues) formData.append(state.name, value);
|
|
692
|
+
state.values = formValues;
|
|
693
|
+
state.internals.setFormValue(formData);
|
|
694
|
+
updateValidity(state);
|
|
695
|
+
dispatch(combobox, "change");
|
|
696
|
+
}
|
|
697
|
+
function updateSelect(update, state, next, added) {
|
|
698
|
+
if (update !== "set") return;
|
|
699
|
+
if (!state.multiple) {
|
|
700
|
+
state.anchor.textContent = next.textContent ?? "";
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (added === true) state.anchor.querySelector(`[${ATTRIBUTE_SELECTION}]`)?.append(getSelectionItem(next));
|
|
704
|
+
else if (added === false) state.anchor.querySelector(`[${ATTRIBUTE_SELECTION_ITEM}][option="${next.id}"]`)?.remove();
|
|
705
|
+
}
|
|
706
|
+
function updateSelected(update, state, next) {
|
|
707
|
+
const [all] = getOptions(state);
|
|
708
|
+
for (const option of all) setAria(option, "selected", getSelected(update, state, option, next));
|
|
709
|
+
if (next == null) return;
|
|
710
|
+
if (state.type !== "select") {
|
|
711
|
+
state.selected = [next];
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (update !== "set") return;
|
|
715
|
+
if (!state.multiple) {
|
|
716
|
+
state.selected = [next];
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const index = state.selected.indexOf(next);
|
|
720
|
+
const added = index === -1;
|
|
721
|
+
if (added) state.selected.push(next);
|
|
722
|
+
else state.selected.splice(index, 1);
|
|
723
|
+
return added;
|
|
724
|
+
}
|
|
725
|
+
function updateValidity(state) {
|
|
726
|
+
if (state.autocomplete.none) {
|
|
727
|
+
const valueMissing = state.required && state.values[0].length === 0;
|
|
728
|
+
state.internals.setValidity({ valueMissing }, INVALID_INPUT);
|
|
729
|
+
state.internals.checkValidity();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const rangeOverflow = state.multiple && typeof state.max === "number" && state.selected.length > state.max;
|
|
733
|
+
const rangeUnderflow = state.multiple && typeof state.min === "number" && state.selected.length < state.min;
|
|
734
|
+
const badInput = state.required && state.selected.length === 0;
|
|
735
|
+
state.internals.setValidity({
|
|
736
|
+
badInput,
|
|
737
|
+
rangeOverflow,
|
|
738
|
+
rangeUnderflow
|
|
739
|
+
}, join([
|
|
740
|
+
badInput ? INVALID_BAD : void 0,
|
|
741
|
+
rangeOverflow ? getString(INVALID_OVERFLOW).replace("{max}", state.max.toString()) : void 0,
|
|
742
|
+
rangeUnderflow ? getString(INVALID_UNDERFLOW).replace("{min}", state.min.toString()) : void 0
|
|
743
|
+
], "; "));
|
|
744
|
+
state.internals.checkValidity();
|
|
745
|
+
}
|
|
746
|
+
function updateWidth(state) {
|
|
747
|
+
setStyle(state.content, PROPERTY_STYLE, `${state.anchor.getBoundingClientRect().width}px`);
|
|
748
|
+
}
|
|
749
|
+
const TAGNAME = "oui-combobox";
|
|
750
|
+
const ATTRIBUTE_ANCHOR = `${TAGNAME}-anchor`;
|
|
751
|
+
const ATTRIBUTE_CONTENT = `${TAGNAME}-content`;
|
|
752
|
+
const ATTRIBUTE_EMPTY = `${TAGNAME}-empty`;
|
|
753
|
+
const ATTRIBUTE_LIST = `${TAGNAME}-list`;
|
|
754
|
+
const ATTRIBUTE_OPTION = `${TAGNAME}-option`;
|
|
755
|
+
const ATTRIBUTE_OPTION_ACTIVE = `${TAGNAME}-option-active`;
|
|
756
|
+
const ATTRIBUTE_SELECTION = `${TAGNAME}-selection`;
|
|
757
|
+
const ATTRIBUTE_SELECTION_ITEM = `${TAGNAME}-selection-item`;
|
|
758
|
+
const AUTOCOMPLETES = /* @__PURE__ */ new Set([
|
|
759
|
+
"both",
|
|
760
|
+
"inline",
|
|
761
|
+
"list",
|
|
762
|
+
"none"
|
|
763
|
+
]);
|
|
764
|
+
const EXPRESSION_TYPED = /\p{L}|\s/u;
|
|
765
|
+
const INVALID_BAD = "Please select an option from the list";
|
|
766
|
+
const INVALID_INPUT = "Please enter a value";
|
|
767
|
+
const INVALID_OVERFLOW = "Please select fewer options (maximum is {max})";
|
|
768
|
+
const INVALID_UNDERFLOW = "Please select more options (minimum is {min})";
|
|
769
|
+
const KEYS_ABSOLUTE = /* @__PURE__ */ new Set(["End", "Home"]);
|
|
770
|
+
const KEYS_NAVIGATION = /* @__PURE__ */ new Set([
|
|
771
|
+
"ArrowDown",
|
|
772
|
+
"ArrowUp",
|
|
773
|
+
"PageDown",
|
|
774
|
+
"PageUp"
|
|
775
|
+
]);
|
|
776
|
+
const KEYS_REMOVE = /* @__PURE__ */ new Set(["Backspace", "Delete"]);
|
|
777
|
+
const KEYS_SELECTION = /* @__PURE__ */ new Set(["ArrowRight", "End"]);
|
|
778
|
+
const MESSAGE_LABEL = `<${TAGNAME}> must contain a <label> element for the <select> element`;
|
|
779
|
+
const MESSAGE_SELECT = `<${TAGNAME}> must contain a valid <select> element`;
|
|
780
|
+
const PROPERTY_STYLE = "--oui-combobox-width";
|
|
781
|
+
const SELECTOR_ANCHOR = `[${ATTRIBUTE_ANCHOR}]`;
|
|
782
|
+
const SELECTOR_OPTION = `[${ATTRIBUTE_OPTION}]`;
|
|
783
|
+
const TYPES = /* @__PURE__ */ new Set(["autocomplete", "select"]);
|
|
784
|
+
const comboboxes = /* @__PURE__ */ new WeakMap();
|
|
785
|
+
const scrollOptions = {
|
|
786
|
+
behavior: "smooth",
|
|
787
|
+
block: "nearest",
|
|
788
|
+
inline: "nearest"
|
|
789
|
+
};
|
|
790
|
+
const states = /* @__PURE__ */ new WeakMap();
|
|
791
|
+
let index = 0;
|
|
792
|
+
on(document, "click", onClick);
|
|
793
|
+
on(document, "keydown", onKeydown, { passive: false });
|
|
794
|
+
on(document, "keyup", onKeyup, { passive: false });
|
|
795
|
+
customElements.define(TAGNAME, OuiComboboxElement);
|
|
796
|
+
//#endregion
|
|
797
|
+
export { OuiComboboxElement };
|