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