@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,7 +1,7 @@
1
1
  import {clamp} from '@oscarpalmer/atoms/number';
2
- import {getString} from '@oscarpalmer/atoms/string';
3
- import {getAria, setAria} from '@oscarpalmer/toretto/aria';
4
- import {getAttribute, setAttribute, setAttributes} from '@oscarpalmer/toretto/attribute';
2
+ import {getString, join} from '@oscarpalmer/atoms/string';
3
+ import {getAria, setAria, setRole} from '@oscarpalmer/toretto/aria';
4
+ import {getAttribute, setAttribute} from '@oscarpalmer/toretto/attribute';
5
5
  import {createElement} from '@oscarpalmer/toretto/create';
6
6
  import {dispatch, on} from '@oscarpalmer/toretto/event';
7
7
  import {findAncestor} from '@oscarpalmer/toretto/find';
@@ -21,62 +21,148 @@ declare global {
21
21
  export class OuiComboboxElement extends HTMLElement {
22
22
  static formAssociated = true;
23
23
 
24
- static observedAttributes = ['disabled', 'required', 'value'];
24
+ static observedAttributes = ['disabled', 'max', 'min', 'readonly', 'required', 'value'];
25
25
 
26
26
  readonly #internals: ElementInternals;
27
27
 
28
28
  readonly #state: OuiComboboxState;
29
29
 
30
+ /**
31
+ * Is the combobox disabled?
32
+ */
30
33
  get disabled(): boolean {
31
- return this.#state?.disabled ?? false;
34
+ return this.hasAttribute('disabled');
32
35
  }
33
36
 
37
+ /**
38
+ * Should the combobox be disabled?
39
+ */
34
40
  set disabled(value: boolean) {
35
41
  if (typeof value === 'boolean' && value !== this.#state.disabled) {
36
42
  setDisabled(this, this.#state, value);
37
43
  }
38
44
  }
39
45
 
46
+ /**
47
+ * Maximum number of selected options
48
+ */
49
+ get max(): number | undefined {
50
+ return this.#state.max;
51
+ }
52
+
53
+ /**
54
+ * Set the maximum number of selected options
55
+ */
56
+ set max(value: number) {
57
+ setLimit('max', this, this.#state, value);
58
+ }
59
+
60
+ /**
61
+ * Minimum number of selected options
62
+ */
63
+ get min(): number | undefined {
64
+ return this.#state.min;
65
+ }
66
+
67
+ /**
68
+ * Set the minimum number of selected options
69
+ */
70
+ set min(value: number) {
71
+ setLimit('min', this, this.#state, value);
72
+ }
73
+
74
+ /**
75
+ * Does the combobox allow multiple values?
76
+ */
40
77
  get multiple(): boolean {
41
78
  return this.hasAttribute('multiple');
42
79
  }
43
80
 
81
+ /**
82
+ * Name of the combobox
83
+ */
44
84
  get name(): string {
45
- return this.getAttribute('name') ?? '';
85
+ return getAttribute(this, 'name') ?? '';
86
+ }
87
+
88
+ /**
89
+ * Is the combobox readonly?
90
+ */
91
+ get readonly(): boolean {
92
+ return this.hasAttribute('readonly');
46
93
  }
47
94
 
48
- set name(value: string) {
49
- if (typeof value === 'string') {
50
- this.setAttribute('name', value);
95
+ /**
96
+ * Should the combobox be readonly?
97
+ */
98
+ set readonly(value: boolean) {
99
+ if (typeof value === 'boolean' && value !== this.#state.readonly) {
100
+ setReadonly(this, this.#state, value);
51
101
  }
52
102
  }
53
103
 
104
+ /**
105
+ * Is the combobox required?
106
+ */
54
107
  get required(): boolean {
55
108
  return this.hasAttribute('required');
56
109
  }
57
110
 
111
+ /**
112
+ * Should the combobox be required?
113
+ */
58
114
  set required(value: boolean) {
59
115
  if (typeof value === 'boolean' && value !== this.#state.required) {
60
116
  setRequired(this, this.#state, value);
61
117
  }
62
118
  }
63
119
 
120
+ /**
121
+ * Selected options
122
+ */
123
+ get selected(): HTMLElement[] {
124
+ return this.#state.selected.slice();
125
+ }
126
+
127
+ /**
128
+ * Type of combobox
129
+ */
64
130
  get type(): OuiComboboxType {
65
131
  return this.#state.type;
66
132
  }
67
133
 
134
+ /**
135
+ * Current value
136
+ */
68
137
  get value(): string {
69
- return this.#state.value;
138
+ return this.#state.values[0];
70
139
  }
71
140
 
141
+ /**
142
+ * Set the value
143
+ */
72
144
  set value(value: unknown) {
73
145
  const [option, all] = getOption(this.#state, value);
74
146
 
75
147
  if (option != null) {
76
- setNext(this, this.#state, all, 'set', option);
148
+ setNext('set', this, this.#state, all, option);
77
149
  }
78
150
  }
79
151
 
152
+ /**
153
+ * Current values
154
+ */
155
+ get values(): string[] {
156
+ return this.#state.values.slice();
157
+ }
158
+
159
+ /**
160
+ * Set the values
161
+ */
162
+ set values(value: unknown[]) {
163
+ setValues(this, this.#state, value);
164
+ }
165
+
80
166
  constructor() {
81
167
  super();
82
168
 
@@ -92,20 +178,38 @@ export class OuiComboboxElement extends HTMLElement {
92
178
  attributeChangedCallback(name: string, _: string | null, value: string | null): void {
93
179
  switch (name) {
94
180
  case 'disabled':
95
- this.disabled = this.hasAttribute('disabled');
181
+ case 'readonly':
182
+ case 'required':
183
+ case 'value':
184
+ this[name] = this.hasAttribute(name);
96
185
  break;
97
186
 
98
- case 'required':
99
- this.required = this.hasAttribute('required');
187
+ case 'max':
188
+ case 'min':
189
+ setLimit(name, this, this.#state, value == null ? undefined : Number.parseInt(value, 10));
100
190
  break;
101
191
 
102
- case 'value':
103
- this.value = value;
192
+ default:
104
193
  break;
105
194
  }
106
195
  }
196
+
197
+ clear(): void {
198
+ setValues(this, this.#state, []);
199
+ }
200
+
201
+ formResetCallback(): void {
202
+ this.#state.touched = false;
203
+
204
+ this.clear();
205
+ }
107
206
  }
108
207
 
208
+ type OuiComboboxFilter = {
209
+ element?: HTMLInputElement;
210
+ value: string;
211
+ };
212
+
109
213
  type OuiComboboxAutoComplete = Record<OuiComboboxAutoCompleteType, boolean> & {
110
214
  value: OuiComboboxAutoCompleteType;
111
215
  };
@@ -118,126 +222,97 @@ type OuiComboboxState = {
118
222
  autocomplete: OuiComboboxAutoComplete;
119
223
  content: HTMLElement;
120
224
  disabled: boolean;
121
- filter: string;
225
+ empty: HTMLElement;
226
+ filter: OuiComboboxFilter;
122
227
  floatable: OuiFloatable;
228
+ headers?: HTMLElement[];
123
229
  id: string;
124
230
  internals: ElementInternals;
125
231
  label: HTMLLabelElement;
126
232
  listeners: RemovableEventListener[];
233
+ max?: number;
234
+ min?: number;
235
+ name: string;
127
236
  multiple: boolean;
237
+ readonly: boolean;
128
238
  required: boolean;
129
239
  select: HTMLSelectElement;
130
240
  selected: HTMLElement[];
241
+ selection?: HTMLElement;
242
+ touched: boolean;
131
243
  type: OuiComboboxType;
132
- value: string;
244
+ values: string[];
133
245
  };
134
246
 
135
247
  type OuiComboboxType = 'autocomplete' | 'select';
136
248
 
249
+ type OuiComboboxUpdate = 'navigation' | 'none' | 'remove' | 'set' | 'typed';
250
+
137
251
  // #endregion
138
252
 
139
253
  // #region Functions
140
254
 
141
- function filterOptions(
142
- state: OuiComboboxState,
143
- options?: HTMLElement[],
144
- clear?: boolean,
145
- ): HTMLElement | undefined {
146
- if (clear || state.autocomplete.none) {
147
- state.filter = '';
148
- }
149
-
150
- const filter = state.filter.toLocaleLowerCase();
151
-
152
- const all = options ?? getOptions(state)[0];
153
- const {length} = all;
154
-
155
- const visible: HTMLElement[] = [];
156
-
157
- for (let index = 0; index < length; index += 1) {
158
- const option = all[index];
159
-
160
- option.hidden = option.textContent?.toLocaleLowerCase().includes(filter) === false;
161
-
162
- if (!option.hidden) {
163
- visible.push(option);
164
- }
165
- }
166
-
167
- if (visible.length > 0) {
168
- return state.active != null && visible.includes(state.active) ? state.active : visible[0];
169
- }
170
- }
171
-
172
- function getAnchor(
255
+ function createAnchor(
173
256
  type: OuiComboboxType,
174
257
  id: string,
175
- element: OuiComboboxElement,
176
258
  label: HTMLLabelElement,
177
259
  select: HTMLSelectElement,
178
260
  autocomplete: OuiComboboxAutoComplete,
179
- ): HTMLElement {
180
- const anchor =
181
- type === 'autocomplete'
182
- ? getInput(id, element, label, select)
183
- : getBox(id, element, label, select);
184
-
185
- setAria(anchor, 'autocomplete', autocomplete.value);
186
-
187
- setAttributes(anchor, {
188
- [ATTRIBUTE_ANCHOR]: '',
189
- 'aria-controls': `${id}_content`,
190
- 'aria-expanded': false,
191
- 'aria-haspopup': 'listbox',
192
- 'aria-labelledby': label.id,
193
- role: 'combobox',
261
+ multiple: boolean,
262
+ ): [HTMLElement, HTMLElement | undefined] {
263
+ const [anchor, selection] =
264
+ type === 'autocomplete' ? [createInput(id, select)] : createBox(id, select, multiple);
265
+
266
+ setAria(anchor, {
267
+ autocomplete: type === 'autocomplete' ? autocomplete.value : undefined,
268
+ controls: `${id}_content`,
269
+ expanded: false,
270
+ haspopup: 'listbox',
271
+ labelledby: label.id,
194
272
  });
195
273
 
196
- return anchor;
197
- }
198
-
199
- function getAutoComplete(element: OuiComboboxElement, select: boolean): OuiComboboxAutoComplete {
200
- let value: OuiComboboxAutoCompleteType;
274
+ setAttribute(anchor, ATTRIBUTE_ANCHOR, '');
275
+ setRole(anchor, 'combobox');
201
276
 
202
- if (select) {
203
- value = 'list';
204
- } else {
205
- const attribute = getAttribute(element, 'autocomplete') as OuiComboboxAutoCompleteType;
206
-
207
- value = AUTOCOMPLETES.has(attribute) ? attribute : 'none';
208
- }
209
-
210
- return {
211
- value,
212
- both: value === 'both',
213
- inline: value === 'inline',
214
- list: value === 'list',
215
- none: value === 'none',
216
- };
277
+ return [anchor, selection];
217
278
  }
218
279
 
219
- function getBox(
280
+ function createBox(
220
281
  id: string,
221
- element: OuiComboboxElement,
222
- label: HTMLLabelElement,
223
282
  select: HTMLSelectElement,
224
- ): HTMLElement {
225
- return createElement('div', {
283
+ multiple: boolean,
284
+ ): [HTMLElement, HTMLElement | undefined] {
285
+ const box = createElement('div', {
226
286
  id,
227
- innerHTML: select.selectedOptions[0]?.textContent ?? '',
228
287
  tabIndex: 0,
288
+ textContent: multiple ? '' : (select.selectedOptions[0]?.textContent ?? ''),
229
289
  });
290
+
291
+ let selection: HTMLElement | undefined;
292
+
293
+ if (multiple) {
294
+ selection = createSelection(select);
295
+
296
+ box.append(selection);
297
+ }
298
+
299
+ return [box, selection];
230
300
  }
231
301
 
232
- function getContent(
302
+ function createContent(
233
303
  type: OuiComboboxType,
234
304
  id: string,
235
- element: OuiComboboxElement,
236
- anchor: HTMLElement,
237
305
  label: HTMLLabelElement,
238
306
  select: HTMLSelectElement,
239
307
  autocomplete: OuiComboboxAutoComplete,
240
- ): [HTMLElement, HTMLElement[]] {
308
+ multiple: boolean,
309
+ ): [
310
+ HTMLElement,
311
+ HTMLInputElement | undefined,
312
+ HTMLElement[] | undefined,
313
+ HTMLElement[],
314
+ HTMLElement,
315
+ ] {
241
316
  const content = createElement(
242
317
  'div',
243
318
  {
@@ -246,41 +321,201 @@ function getContent(
246
321
  {
247
322
  [ATTRIBUTE_CONTENT]: '',
248
323
  popover: '',
249
- role: 'listbox',
250
324
  tabindex: -1,
251
325
  'aria-labelledby': label.id,
252
326
  },
253
327
  );
254
328
 
329
+ const [filter, input] = createFilter(type, id) ?? [];
330
+ const [list, headers, selected] = createList(label, select, autocomplete, multiple);
331
+ const empty = createEmpty();
332
+
333
+ if (filter != null) {
334
+ content.append(filter);
335
+ }
336
+
337
+ content.append(list, empty);
338
+
339
+ return [content, input, headers, selected, empty];
340
+ }
341
+
342
+ function createEmpty(): HTMLElement {
343
+ return createElement(
344
+ 'div',
345
+ {
346
+ hidden: true,
347
+ role: 'status',
348
+ textContent: 'No results found',
349
+ },
350
+ {
351
+ [ATTRIBUTE_EMPTY]: '',
352
+ 'aria-live': 'polite',
353
+ },
354
+ );
355
+ }
356
+
357
+ function createFilter(
358
+ type: OuiComboboxType,
359
+ id: string,
360
+ ): [HTMLElement, HTMLInputElement] | undefined {
361
+ if (type === 'autocomplete') {
362
+ return;
363
+ }
364
+
365
+ const container = createElement(
366
+ 'div',
367
+ {},
368
+ {
369
+ [ATTRIBUTE_FILTER]: '',
370
+ },
371
+ );
372
+
373
+ const label = createElement(
374
+ 'label',
375
+ {
376
+ htmlFor: `${id}_filter`,
377
+ textContent: 'Filter options',
378
+ },
379
+ {
380
+ [ATTRIBUTE_FILTER_LABEL]: '',
381
+ },
382
+ );
383
+
384
+ const filter = createElement(
385
+ 'input',
386
+ {
387
+ id: `${id}_filter`,
388
+ },
389
+ {
390
+ [ATTRIBUTE_FILTER_INPUT]: '',
391
+ },
392
+ );
393
+
394
+ container.append(label, filter);
395
+
396
+ return [container, filter];
397
+ }
398
+
399
+ function createHeader(group: HTMLOptGroupElement): HTMLElement {
400
+ return createElement(
401
+ 'li',
402
+ {
403
+ id: group.id,
404
+ role: 'presentation',
405
+ textContent: group.label,
406
+ },
407
+ {
408
+ [ATTRIBUTE_HEADER]: '',
409
+ },
410
+ );
411
+ }
412
+
413
+ function createInput(id: string, select: HTMLSelectElement): HTMLInputElement {
414
+ return createElement(
415
+ 'input',
416
+ {
417
+ id,
418
+ value: select.selectedOptions[0]?.textContent ?? '',
419
+ },
420
+ {
421
+ [ATTRIBUTE_ANCHOR]: '',
422
+ autocomplete: 'off',
423
+ type: 'text',
424
+ },
425
+ );
426
+ }
427
+
428
+ function createList(
429
+ label: HTMLLabelElement,
430
+ select: HTMLSelectElement,
431
+ autocomplete: OuiComboboxAutoComplete,
432
+ multiple: boolean,
433
+ ): [HTMLElement, HTMLElement[] | undefined, HTMLElement[]] {
255
434
  const list = createElement(
256
435
  'ul',
257
- {},
436
+ {
437
+ role: 'listbox',
438
+ },
258
439
  {
259
440
  [ATTRIBUTE_LIST]: '',
441
+ 'aria-labelledby': label.id,
442
+ 'aria-multiselectable': multiple,
260
443
  },
261
444
  );
262
445
 
263
- const options = [...select.options];
446
+ const groups = [...select.querySelectorAll('optgroup')];
447
+ const groupsLength = groups.length;
264
448
 
265
449
  const selected: HTMLElement[] = [];
266
450
 
451
+ let headers: HTMLElement[] | undefined;
452
+
453
+ if (groupsLength > 0) {
454
+ headers = [];
455
+
456
+ let firstGroup: number | undefined;
457
+
458
+ for (let groupIndex = 0; groupIndex < groupsLength; groupIndex += 1) {
459
+ const group = groups[groupIndex];
460
+ const options = [...group.querySelectorAll('option')];
461
+ const optionsLength = options.length;
462
+
463
+ if (optionsLength === 0) {
464
+ continue;
465
+ }
466
+
467
+ firstGroup ??= groupIndex;
468
+
469
+ const header = createHeader(group);
470
+
471
+ headers.push(header);
472
+ list.append(header);
473
+
474
+ createOptions(options, autocomplete, list, selected, groupIndex === firstGroup, header.id);
475
+ }
476
+ } else {
477
+ createOptions([...select.options], autocomplete, list, selected, true);
478
+ }
479
+
480
+ return [list, headers, selected];
481
+ }
482
+
483
+ function createOption(
484
+ index: number,
485
+ option: HTMLOptionElement,
486
+ autocomplete: OuiComboboxAutoComplete,
487
+ active: boolean,
488
+ group?: string,
489
+ ): HTMLElement {
490
+ return createElement(
491
+ 'li',
492
+ {
493
+ id: option.id,
494
+ role: 'option',
495
+ textContent: option.textContent,
496
+ },
497
+ {
498
+ group,
499
+ [ATTRIBUTE_OPTION]: '',
500
+ [ATTRIBUTE_OPTION_ACTIVE]: active && index === 0 ? '' : undefined,
501
+ value: option.value,
502
+ 'aria-selected': !autocomplete.none && option.selected,
503
+ },
504
+ );
505
+ }
506
+
507
+ function createOptions(
508
+ options: HTMLOptionElement[],
509
+ autocomplete: OuiComboboxAutoComplete,
510
+ list: HTMLElement,
511
+ selected: HTMLElement[],
512
+ active: boolean,
513
+ group?: string,
514
+ ): void {
267
515
  for (let index = 0; index < options.length; index += 1) {
268
516
  const option = options[index];
269
517
 
270
- const item = createElement(
271
- 'li',
272
- {
273
- id: `${id}_option_${index}`,
274
- textContent: option.textContent,
275
- },
276
- {
277
- [ATTRIBUTE_OPTION]: '',
278
- [ATTRIBUTE_OPTION_CURRENT]: index === 0 ? '' : undefined,
279
- role: 'option',
280
- value: option.value,
281
- 'aria-selected': !autocomplete.none && option.selected,
282
- },
283
- );
518
+ const item = createOption(index, option, autocomplete, active, group);
284
519
 
285
520
  if (option.selected) {
286
521
  selected.push(item);
@@ -288,10 +523,150 @@ function getContent(
288
523
 
289
524
  list.append(item);
290
525
  }
526
+ }
291
527
 
292
- content.append(list);
528
+ function createSelection(select: HTMLSelectElement): HTMLElement {
529
+ const selection = createElement(
530
+ 'ul',
531
+ {},
532
+ {
533
+ [ATTRIBUTE_SELECTION]: '',
534
+ },
535
+ );
293
536
 
294
- return [content, selected];
537
+ for (const option of select.selectedOptions) {
538
+ selection.append(getSelectionItem(option));
539
+ }
540
+
541
+ return selection;
542
+ }
543
+
544
+ function filterOptions(
545
+ state: OuiComboboxState,
546
+ options?: HTMLElement[],
547
+ clear?: boolean,
548
+ ): HTMLElement | undefined {
549
+ if (clear || state.autocomplete.none) {
550
+ state.filter.value = '';
551
+ }
552
+
553
+ const filter = state.filter.value.toLocaleLowerCase();
554
+
555
+ const groups = (state.headers?.reduce(
556
+ (record, header) => {
557
+ record[header.id] = 0;
558
+
559
+ return record;
560
+ },
561
+ {} as Record<string, number>,
562
+ ) ?? {}) as Record<string, number>;
563
+
564
+ const all = options ?? getOptions(state)[0];
565
+ let {length} = all;
566
+
567
+ const visible: HTMLElement[] = [];
568
+
569
+ for (let index = 0; index < length; index += 1) {
570
+ const option = all[index];
571
+
572
+ option.hidden = option.textContent?.toLocaleLowerCase().includes(filter) === false;
573
+
574
+ if (!option.hidden) {
575
+ groups[getAttribute(option, 'group') ?? ''] += 1;
576
+
577
+ visible.push(option);
578
+ }
579
+ }
580
+
581
+ if (state.headers != null) {
582
+ length = state.headers.length;
583
+
584
+ for (let index = 0; index < length; index += 1) {
585
+ const header = state.headers[index];
586
+
587
+ header.hidden = groups[header.id] === 0;
588
+ }
589
+ }
590
+
591
+ state.empty.hidden = visible.length > 0;
592
+
593
+ if (state.empty.hidden) {
594
+ return state.active != null && visible.includes(state.active) ? state.active : visible[0];
595
+ }
596
+ }
597
+
598
+ function generateIds(select: HTMLSelectElement, prefix: string): void {
599
+ const elements = [...select.querySelectorAll('optgroup'), ...select.options];
600
+ const {length} = elements;
601
+
602
+ for (let index = 0; index < length; index += 1) {
603
+ const element = elements[index];
604
+
605
+ if (element.id === '') {
606
+ element.id = `${prefix}${element instanceof HTMLOptGroupElement ? '_group_' : '_option_'}${index}`;
607
+ }
608
+ }
609
+ }
610
+
611
+ function getAttributeValue<Value>(
612
+ element: HTMLElement,
613
+ attribute: string,
614
+ possible: Set<Value>,
615
+ defaultValue: Value,
616
+ ): Value {
617
+ const value = getAttribute(element, attribute);
618
+
619
+ return possible.has(value as Value) ? (value as Value) : defaultValue;
620
+ }
621
+
622
+ function getAutoComplete(element: OuiComboboxElement, select: boolean): OuiComboboxAutoComplete {
623
+ let value: OuiComboboxAutoCompleteType;
624
+
625
+ if (select) {
626
+ value = 'list';
627
+ } else {
628
+ value = getAttributeValue(element, 'autocomplete', AUTOCOMPLETES, 'none');
629
+ }
630
+
631
+ return {
632
+ value,
633
+ both: value === 'both',
634
+ inline: value === 'inline',
635
+ list: value === 'list',
636
+ none: value === 'none',
637
+ };
638
+ }
639
+
640
+ function getFormValue(state: OuiComboboxState, values: string[]): string {
641
+ if (values.length === 0 || !state.multiple) {
642
+ return values[0];
643
+ }
644
+
645
+ let formValues: string[] = [];
646
+
647
+ for (const value of values) {
648
+ formValues.push(`${state.name}=${value}`);
649
+ }
650
+
651
+ return join(formValues, '&');
652
+ }
653
+
654
+ function getFormValues(state: OuiComboboxState, added?: boolean): [string[], boolean] {
655
+ let values: string[];
656
+
657
+ if (state.multiple) {
658
+ values =
659
+ typeof added === 'boolean'
660
+ ? state.selected.map(option => getAttribute(option, 'value') ?? '')
661
+ : state.values;
662
+ } else {
663
+ values = [getAttribute(state.selected[0], 'value') ?? ''];
664
+ }
665
+
666
+ const previous = getFormValue(state, state.values);
667
+ const next = getFormValue(state, values);
668
+
669
+ return [values, next !== previous];
295
670
  }
296
671
 
297
672
  function getId(): string {
@@ -300,40 +675,21 @@ function getId(): string {
300
675
  return `oui_combobox_${index}`;
301
676
  }
302
677
 
303
- function getInput(
304
- id: string,
305
- element: OuiComboboxElement,
306
- label: HTMLLabelElement,
307
- select: HTMLSelectElement,
308
- ): HTMLInputElement {
309
- return createElement(
310
- 'input',
311
- {
312
- id,
313
- value: select.selectedOptions[0]?.textContent ?? '',
314
- },
315
- {
316
- [ATTRIBUTE_ANCHOR]: '',
317
- autocomplete: 'off',
318
- type: 'text',
319
- },
320
- );
321
- }
322
-
323
678
  function getOption(
324
679
  state: OuiComboboxState,
325
680
  value: unknown,
326
681
  ): [HTMLElement | undefined, HTMLElement[]] {
327
682
  const [all] = getOptions(state);
328
- const valueAsString = getString(value);
683
+ const valueAsString = getString(value).toLocaleLowerCase();
329
684
 
330
685
  filterOptions(state, all, true);
331
686
 
332
687
  let match: HTMLElement | undefined;
333
688
 
334
689
  for (const option of all) {
335
- if (option.getAttribute('value')?.toLocaleLowerCase() === valueAsString) {
690
+ if (getAttribute(option, 'value')?.toLocaleLowerCase() === valueAsString) {
336
691
  match = option;
692
+
337
693
  break;
338
694
  }
339
695
  }
@@ -352,7 +708,7 @@ function getOptions(state: OuiComboboxState): HTMLElement[][] {
352
708
  }
353
709
 
354
710
  function getSelected(
355
- update: 'navigation' | 'none' | 'set' | 'typed',
711
+ update: OuiComboboxUpdate,
356
712
  state: OuiComboboxState,
357
713
  element: HTMLElement,
358
714
  next?: HTMLElement,
@@ -361,29 +717,68 @@ function getSelected(
361
717
  return false;
362
718
  }
363
719
 
720
+ const selected = getAria(element, 'selected') === 'true';
721
+
364
722
  if (update === 'navigation') {
365
723
  if (state.autocomplete.both) {
366
724
  return element === next;
367
725
  }
368
726
 
369
- return getAria(element, 'selected') === 'true';
727
+ return selected;
370
728
  }
371
729
 
372
730
  if (update === 'none') {
373
- return getAria(element, 'selected') === 'true';
731
+ return selected;
374
732
  }
375
733
 
376
734
  if (update === 'set') {
735
+ if (next == null) {
736
+ return selected;
737
+ }
738
+
739
+ if (state.multiple) {
740
+ return element === next ? !selected : selected;
741
+ }
742
+
377
743
  return element === next;
378
744
  }
379
745
 
380
- return state.autocomplete.both && element === next;
746
+ return selected;
747
+ }
748
+
749
+ function getSelectionItem(option: HTMLElement): HTMLElement {
750
+ const icon = createElement(
751
+ 'span',
752
+ {
753
+ innerHTML: '&times;',
754
+ },
755
+ {
756
+ 'aria-hidden': true,
757
+ },
758
+ );
759
+
760
+ const label = createElement('span', {
761
+ textContent: option.textContent,
762
+ });
763
+
764
+ const item = createElement(
765
+ 'li',
766
+ {},
767
+ {
768
+ [ATTRIBUTE_SELECTION_ITEM]: '',
769
+ option: option.id,
770
+ },
771
+ );
772
+
773
+ item.append(icon, label);
774
+
775
+ return item;
381
776
  }
382
777
 
383
778
  function getState(element: OuiComboboxElement, internals: ElementInternals): OuiComboboxState {
384
779
  const select = element.querySelector('select');
385
780
 
386
- if (select == null) {
781
+ if (select == null || select.id === '') {
387
782
  throw new Error(MESSAGE_SELECT);
388
783
  }
389
784
 
@@ -396,22 +791,37 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
396
791
  select.hidden = true;
397
792
 
398
793
  element.id = select.id;
399
- element.name = select.name === '' ? select.id : select.name;
794
+
795
+ setAttribute(element, 'name', select.name === '' ? select.id : select.name);
400
796
 
401
797
  const disabled = element.disabled || select.disabled;
402
798
  const multiple = element.multiple || select.multiple;
799
+ const readonly = element.readonly;
403
800
  const required = element.required || select.required;
404
801
 
405
802
  const id = getId();
406
- const type = multiple ? 'select' : getType(element);
803
+ const type = multiple ? 'select' : getAttributeValue(element, 'type', TYPES, 'select');
407
804
 
408
805
  const autocomplete = getAutoComplete(element, type === 'select');
409
806
 
410
807
  label.htmlFor = id;
411
808
  label.id = `${id}_label`;
412
809
 
413
- const anchor = getAnchor(type, id, element, label, select, autocomplete);
414
- const [content, selected] = getContent(type, id, element, anchor, label, select, autocomplete);
810
+ generateIds(select, id);
811
+
812
+ const [anchor, selection] = createAnchor(type, id, label, select, autocomplete, multiple);
813
+
814
+ const [content, filter, headers, selected, empty] = createContent(
815
+ type,
816
+ id,
817
+ label,
818
+ select,
819
+ autocomplete,
820
+ multiple,
821
+ );
822
+
823
+ const values =
824
+ selected.length === 0 ? [''] : selected.map(option => getAttribute(option, 'value') ?? '');
415
825
 
416
826
  const floatable = createEmbeddedFloatable(anchor, content, {
417
827
  position: 'below-start',
@@ -430,21 +840,36 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
430
840
  autocomplete,
431
841
  content,
432
842
  disabled,
843
+ empty,
433
844
  floatable,
845
+ headers,
434
846
  id,
435
847
  internals,
436
848
  label,
437
849
  multiple,
850
+ readonly,
438
851
  required,
439
852
  select,
440
853
  selected,
854
+ selection,
441
855
  type,
442
- filter: '',
856
+ values,
857
+ active: selected[0],
858
+ filter: {
859
+ element: filter,
860
+ value: '',
861
+ },
443
862
  listeners: [],
444
- value: '',
863
+ name: element.name,
864
+ touched: false,
445
865
  };
446
866
 
447
867
  state.listeners.push(
868
+ on(element, 'focusout', () => {
869
+ state.touched = true;
870
+
871
+ updateValidity(state);
872
+ }),
448
873
  on(content, 'click', event => {
449
874
  onContent(event, content);
450
875
  }),
@@ -455,30 +880,27 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
455
880
  });
456
881
 
457
882
  if (floatable.open) {
458
- setStyle(content, '--oui-combobox-width', `${anchor.getBoundingClientRect().width}px`);
883
+ state.filter.element?.focus();
884
+
885
+ updateWidth(state);
459
886
  }
460
887
  }),
461
888
  );
462
889
 
463
890
  setDisabled(element, state, disabled);
891
+ setLimit('max', element, state, getAttribute(element, 'max'));
892
+ setLimit('min', element, state, getAttribute(element, 'min'));
893
+ setReadonly(element, state, readonly);
894
+ setRequired(element, state, required);
464
895
 
465
896
  return state;
466
897
  }
467
898
 
468
- function getType(element: OuiComboboxElement): OuiComboboxType {
469
- const type = getAttribute(element, ATTRIBUTE_TYPE) as OuiComboboxType;
470
-
471
- return TYPES.has(type) ? type : 'select';
899
+ function hasTextSelection(element: HTMLElement): element is HTMLInputElement {
900
+ return element instanceof HTMLInputElement && element.selectionStart !== element.selectionEnd;
472
901
  }
473
902
 
474
- function onAnchor(
475
- event: KeyboardEvent,
476
- handler: (event: KeyboardEvent, combobox: OuiComboboxElement, state: OuiComboboxState) => void,
477
- ): void {
478
- if (event.ctrlKey || event.metaKey || event.shiftKey) {
479
- return;
480
- }
481
-
903
+ function onClick(event: PointerEvent): void {
482
904
  const combobox = findAncestor(event, TAGNAME);
483
905
  const state = states.get(combobox!);
484
906
 
@@ -486,172 +908,185 @@ function onAnchor(
486
908
  return;
487
909
  }
488
910
 
911
+ const label = findAncestor(event, 'label');
912
+
913
+ if (label != null) {
914
+ if (label === state.label && combobox.type === 'select') {
915
+ state.anchor.focus();
916
+
917
+ state.floatable.show();
918
+ }
919
+
920
+ return;
921
+ }
922
+
923
+ const selection = findAncestor(event, `[${ATTRIBUTE_SELECTION_ITEM}]`);
924
+
925
+ if (selection != null) {
926
+ const option = state.selected.find(option => option.id === getAttribute(selection, 'option'));
927
+
928
+ setValue('set', combobox, state, option);
929
+
930
+ return;
931
+ }
932
+
489
933
  const anchor = findAncestor(event, SELECTOR_ANCHOR);
490
934
 
491
935
  if (anchor != null) {
492
- handler(event, combobox, state);
936
+ state.floatable.show();
937
+
938
+ return;
493
939
  }
494
940
  }
495
941
 
496
- function onAnchorKeydown(
497
- event: KeyboardEvent,
498
- combobox: OuiComboboxElement,
499
- state: OuiComboboxState,
500
- ): void {
501
- let preventDefault = true;
502
-
503
- switch (true) {
504
- case event.key === 'Enter':
505
- onEnter(combobox, state);
506
- break;
942
+ function onContent(event: PointerEvent, content: HTMLElement): void {
943
+ const combobox = comboboxes.get(content);
944
+ const state = states.get(content);
507
945
 
508
- case event.key === 'Escape':
509
- onEscape(combobox, state);
510
- break;
946
+ if (combobox == null || state == null) {
947
+ return;
948
+ }
511
949
 
512
- case state.type === 'select' && KEYS_ABSOLUTE.has(event.key):
513
- onNavigation(event, combobox, state);
514
- break;
950
+ const option = findAncestor(event, SELECTOR_OPTION);
515
951
 
516
- case KEYS_NAVIGATION.has(event.key):
517
- onNavigation(event, combobox, state);
518
- break;
952
+ if (option instanceof HTMLElement) {
953
+ setNext('set', combobox, state, getOptions(state)[0], option);
954
+ }
519
955
 
520
- default: {
521
- preventDefault = false;
956
+ (state.filter.element ?? state.anchor).focus();
957
+ }
522
958
 
523
- if (event.key === 'Tab') {
524
- onTab(combobox, state);
525
- }
959
+ function onEnter(combobox: OuiComboboxElement, state: OuiComboboxState): void {
960
+ if (hasTextSelection(state.anchor)) {
961
+ onSelection(combobox, state);
526
962
 
527
- break;
528
- }
963
+ return;
529
964
  }
530
965
 
531
- if (preventDefault) {
532
- event.preventDefault();
533
- event.stopPropagation();
966
+ if (!state.floatable.open) {
967
+ state.floatable.show();
968
+
969
+ return;
534
970
  }
971
+
972
+ setNext('set', combobox, state, getOptions(state)[0], state.active);
535
973
  }
536
974
 
537
- function onAnchorKeyup(
975
+ function onEscape(state: OuiComboboxState): void {
976
+ state.floatable.hide();
977
+ }
978
+
979
+ function onInput(
538
980
  event: KeyboardEvent,
539
- combobox: OuiComboboxElement,
540
- state: OuiComboboxState,
981
+ handler: (event: KeyboardEvent, combobox: OuiComboboxElement, state: OuiComboboxState) => void,
541
982
  ): void {
542
- if (state.type === 'select') {
983
+ if (event.ctrlKey || event.metaKey) {
543
984
  return;
544
985
  }
545
986
 
546
- let preventDefault = true;
987
+ const combobox = findAncestor(event, TAGNAME);
988
+ const state = states.get(combobox!);
547
989
 
548
- const typed = event.key.length === 1 && EXPRESSION_TYPED.test(event.key);
990
+ if (combobox == null || state == null) {
991
+ return;
992
+ }
549
993
 
550
- if (typed) {
551
- state.filter += event.key;
994
+ const input = findAncestor(event, SELECTOR_ANCHOR) ?? findAncestor(event, SELECTOR_FILTER);
995
+
996
+ if (input != null) {
997
+ handler(event, combobox, state);
552
998
  }
999
+ }
553
1000
 
554
- if (
555
- state.anchor instanceof HTMLInputElement &&
556
- (KEYS_REMOVE.has(event.key) || state.anchor.value.length < state.filter.length)
557
- ) {
558
- state.filter = state.anchor.value;
1001
+ function onInputKeydown(
1002
+ event: KeyboardEvent,
1003
+ combobox: OuiComboboxElement,
1004
+ state: OuiComboboxState,
1005
+ ): void {
1006
+ const isSelect = state.type === 'select';
559
1007
 
560
- filterOptions(state);
561
- }
1008
+ let preventDefault = true;
562
1009
 
563
1010
  switch (true) {
564
- case typed:
565
- onTyped(combobox, state);
1011
+ case event.key === 'Enter':
1012
+ onEnter(combobox, state);
566
1013
  break;
567
1014
 
568
- case KEYS_REMOVE.has(event.key):
569
- onRemove(combobox, state);
1015
+ case event.key === 'Escape':
1016
+ onEscape(state);
570
1017
  break;
571
1018
 
572
- default: {
1019
+ case !isSelect && KEYS_SELECTION.has(event.key): {
573
1020
  preventDefault = false;
574
1021
 
1022
+ if (hasTextSelection(state.anchor)) {
1023
+ onSelection(combobox, state);
1024
+ }
1025
+
575
1026
  break;
576
1027
  }
577
- }
578
-
579
- if (preventDefault) {
580
- event.preventDefault();
581
- event.stopPropagation();
582
- }
583
- }
584
1028
 
585
- function onClick(event: PointerEvent): void {
586
- const combobox = findAncestor(event, TAGNAME);
587
- const state = states.get(combobox!);
1029
+ case KEYS_NAVIGATION.has(event.key):
1030
+ onNavigation(event, combobox, state);
1031
+ break;
588
1032
 
589
- if (combobox == null || state == null) {
590
- return;
591
- }
1033
+ default: {
1034
+ preventDefault = false;
592
1035
 
593
- const label = findAncestor(event, 'label');
1036
+ if (event.key === 'Tab') {
1037
+ onTab(combobox, state);
1038
+ }
594
1039
 
595
- if (label != null) {
596
- if (label === state.label && combobox.type === 'select') {
597
- state.anchor.focus();
1040
+ break;
598
1041
  }
599
-
600
- return;
601
1042
  }
602
1043
 
603
- const anchor = findAncestor(event, SELECTOR_ANCHOR);
604
-
605
- if (anchor != null) {
606
- state.floatable.show();
607
-
608
- return;
1044
+ if (preventDefault) {
1045
+ event.preventDefault();
1046
+ event.stopPropagation();
609
1047
  }
610
1048
  }
611
1049
 
612
- function onContent(event: PointerEvent, content: HTMLElement): void {
613
- const combobox = comboboxes.get(content);
614
- const state = states.get(content);
1050
+ function onInputKeyup(
1051
+ event: KeyboardEvent,
1052
+ combobox: OuiComboboxElement,
1053
+ state: OuiComboboxState,
1054
+ ): void {
1055
+ const filter = state.filter.element ?? state.anchor;
1056
+ const typed = event.key.length === 1 && EXPRESSION_TYPED.test(event.key);
615
1057
 
616
- if (combobox == null || state == null) {
1058
+ if (
1059
+ !(filter instanceof HTMLInputElement) ||
1060
+ !(typed || KEYS_REMOVE.has(event.key) || filter.value.length < state.filter.value.length)
1061
+ ) {
617
1062
  return;
618
1063
  }
619
1064
 
620
- const option = findAncestor(event, SELECTOR_OPTION);
621
-
622
- if (option instanceof HTMLElement) {
623
- setNext(combobox, state, getOptions(state)[0], 'set', option);
624
- }
625
-
626
- state.anchor.focus();
627
- }
1065
+ event.preventDefault();
1066
+ event.stopPropagation();
628
1067
 
629
- function onEnter(combobox: OuiComboboxElement, state: OuiComboboxState): void {
630
- if (state.floatable.open) {
631
- setNext(combobox, state, getOptions(state)[0], 'set', state.active);
1068
+ if (typed) {
1069
+ state.filter.value += event.key;
632
1070
  } else {
633
- state.floatable.show();
1071
+ state.filter.value = filter.value;
634
1072
  }
635
- }
636
1073
 
637
- function onEscape(combobox: OuiComboboxElement, state: OuiComboboxState): void {
638
- if (!state.floatable.open) {
639
- return;
640
- }
1074
+ const [options] = getOptions(state);
1075
+ const next = filterOptions(state, options);
641
1076
 
642
- if (state.type === 'select') {
643
- state.floatable.hide();
1077
+ if (typed) {
1078
+ onTyped(combobox, state, options, next);
644
1079
  } else {
645
- setNext(combobox, state, getOptions(state)[0], 'set', state.active);
1080
+ setNext('remove', combobox, state, options, next);
646
1081
  }
647
1082
  }
648
1083
 
649
1084
  function onKeydown(event: KeyboardEvent): void {
650
- onAnchor(event, onAnchorKeydown);
1085
+ onInput(event, onInputKeydown);
651
1086
  }
652
1087
 
653
1088
  function onKeyup(event: KeyboardEvent): void {
654
- onAnchor(event, onAnchorKeyup);
1089
+ onInput(event, onInputKeyup);
655
1090
  }
656
1091
 
657
1092
  function onNavigation(
@@ -675,8 +1110,8 @@ function onNavigation(
675
1110
 
676
1111
  let next: HTMLElement | undefined;
677
1112
 
678
- if (skip ?? state.active == null) {
679
- next = state.active ?? visible[0];
1113
+ if (skip || state.active == null) {
1114
+ next = state.active ?? visible.at(event.key === 'ArrowDown' ? 0 : -1);
680
1115
  } else {
681
1116
  const current = visible.findIndex(option => option.id === state.active?.id);
682
1117
 
@@ -710,15 +1145,15 @@ function onNavigation(
710
1145
  }
711
1146
  }
712
1147
 
713
- setNext(combobox, state, all, state.autocomplete.none ? 'none' : 'navigation', next);
1148
+ setNext(state.autocomplete.none ? 'none' : 'navigation', combobox, state, all, next);
714
1149
  }
715
1150
 
716
- function onRemove(combobox: OuiComboboxElement, state: OuiComboboxState): void {
717
- const [all] = getOptions(state);
1151
+ function onSelection(combobox: OuiComboboxElement, state: OuiComboboxState): void {
1152
+ state.filter.value = state.anchor instanceof HTMLInputElement ? state.anchor.value : '';
718
1153
 
719
- const next = filterOptions(state, all);
1154
+ filterOptions(state);
720
1155
 
721
- setNext(combobox, state, all, 'none', next);
1156
+ setNext('set', combobox, state, getOptions(state)[0], state.active);
722
1157
  }
723
1158
 
724
1159
  function onTab(combobox: OuiComboboxElement, state: OuiComboboxState): void {
@@ -727,56 +1162,58 @@ function onTab(combobox: OuiComboboxElement, state: OuiComboboxState): void {
727
1162
  }
728
1163
  }
729
1164
 
730
- function onTyped(combobox: OuiComboboxElement, state: OuiComboboxState): void {
731
- function open(): void {
732
- if (
733
- state.anchor instanceof HTMLInputElement &&
734
- state.anchor.value.length > 0 &&
735
- (state.autocomplete.both || state.autocomplete.list)
736
- ) {
737
- state.floatable.show();
738
- }
739
- }
1165
+ function onTyped(
1166
+ combobox: OuiComboboxElement,
1167
+ state: OuiComboboxState,
1168
+ options?: HTMLElement[],
1169
+ next?: HTMLElement,
1170
+ ): void {
1171
+ const filter = state.filter.element ?? state.anchor;
740
1172
 
741
- if (!state.autocomplete.none) {
742
- const next = filterOptions(state);
1173
+ if (!state.autocomplete.inline && filter instanceof HTMLInputElement && filter.value.length > 0) {
1174
+ state.floatable.show();
1175
+ }
743
1176
 
744
- state.active =
745
- next != null &&
746
- next.textContent?.toLocaleLowerCase().indexOf(state.filter.toLocaleLowerCase()) === 0
747
- ? next
748
- : undefined;
1177
+ const all = options ?? getOptions(state)[0];
749
1178
 
750
- if (next != null) {
751
- open();
752
- }
1179
+ const option = state.autocomplete.none
1180
+ ? (state.active ?? all[0])
1181
+ : (next ?? filterOptions(state, options));
753
1182
 
754
- setNext(combobox, state, getOptions(state)[0], 'typed', next);
755
- } else {
756
- open();
757
- }
1183
+ setNext('typed', combobox, state, all, option);
758
1184
  }
759
1185
 
760
1186
  function setDisabled(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
761
- state.disabled = value;
1187
+ setProperties('disabled', 'disabled', combobox, state, value);
1188
+ }
1189
+
1190
+ function setLimit(
1191
+ type: 'max' | 'min',
1192
+ combobox: OuiComboboxElement,
1193
+ state: OuiComboboxState,
1194
+ value?: unknown,
1195
+ ): void {
1196
+ const updateLimit =
1197
+ typeof value === 'number' && !Number.isNaN(value) ? value !== state[type] : value == null;
762
1198
 
763
- setAria(state.anchor, 'disabled', value);
764
- setProperty(combobox, 'disabled', value);
1199
+ if (updateLimit) {
1200
+ state[type] = value as number | undefined;
765
1201
 
766
- if (state.anchor instanceof HTMLInputElement) {
767
- state.anchor.readOnly = value;
1202
+ setAttribute(combobox, type, value);
1203
+
1204
+ updateValidity(state);
768
1205
  }
769
1206
  }
770
1207
 
771
1208
  function setNext(
1209
+ update: OuiComboboxUpdate,
772
1210
  combobox: OuiComboboxElement,
773
1211
  state: OuiComboboxState,
774
1212
  all: HTMLElement[],
775
- update: 'navigation' | 'none' | 'set' | 'typed',
776
1213
  next?: HTMLElement,
777
1214
  ): void {
778
1215
  for (const option of all) {
779
- setAttribute(option, ATTRIBUTE_OPTION_CURRENT, option === next ? '' : undefined);
1216
+ setAttribute(option, ATTRIBUTE_OPTION_ACTIVE, option === next ? '' : undefined);
780
1217
  }
781
1218
 
782
1219
  state.active = next;
@@ -785,67 +1222,323 @@ function setNext(
785
1222
 
786
1223
  next?.scrollIntoView(scrollOptions);
787
1224
 
788
- setValue(combobox, state, update, next);
1225
+ // TODO: offset scroll to prevent options under headers
1226
+
1227
+ setValue(update, combobox, state, next);
789
1228
  }
790
1229
 
791
- function setRequired(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
792
- state.required = value;
1230
+ function setProperties(
1231
+ key: 'disabled' | 'readonly' | 'required',
1232
+ property: 'disabled' | 'readOnly' | 'required',
1233
+ combobox: OuiComboboxElement,
1234
+ state: OuiComboboxState,
1235
+ value: boolean,
1236
+ ): void {
1237
+ state[key] = value;
1238
+
1239
+ setAria(state.anchor, key, value);
1240
+ setProperty(combobox, key, value);
1241
+
1242
+ if (state.filter.element != null) {
1243
+ state.filter.element[property] = value;
1244
+ }
1245
+
1246
+ if (state.anchor instanceof HTMLInputElement) {
1247
+ state.anchor[property] = value;
1248
+ }
1249
+ }
1250
+
1251
+ function setReadonly(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1252
+ setProperties('readonly', 'readOnly', combobox, state, value);
1253
+ }
793
1254
 
794
- setAria(state.anchor, 'required', value);
795
- setProperty(combobox, 'required', value);
1255
+ function setRequired(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1256
+ setProperties('required', 'required', combobox, state, value);
1257
+ updateValidity(state);
796
1258
  }
797
1259
 
798
1260
  function setValue(
1261
+ update: OuiComboboxUpdate,
799
1262
  combobox: OuiComboboxElement,
800
1263
  state: OuiComboboxState,
801
- update: 'navigation' | 'none' | 'set' | 'typed',
802
1264
  next?: HTMLElement,
803
1265
  ): void {
804
- if (state.disabled) {
1266
+ if (state.disabled || state.readonly || (state.type === 'select' && update !== 'set')) {
1267
+ return;
1268
+ }
1269
+
1270
+ let added: boolean | undefined;
1271
+ let shouldUpdate: boolean;
1272
+ let values: string[];
1273
+
1274
+ if (update === 'set' || !(state.anchor instanceof HTMLInputElement)) {
1275
+ added = updateSelected(update, state, next);
1276
+
1277
+ [values, shouldUpdate] = getFormValues(state, added);
1278
+ } else {
1279
+ const [all] = getOptions(state);
1280
+
1281
+ for (const option of all) {
1282
+ setAria(option, 'selected', false);
1283
+ }
1284
+
1285
+ const input = state.anchor.value;
1286
+
1287
+ const content = next?.textContent ?? '';
1288
+ const value = next == null ? '' : (getAttribute(next, 'value') ?? '');
1289
+
1290
+ let selected: HTMLElement | undefined;
1291
+
1292
+ if (content.toLocaleLowerCase() === input.toLocaleLowerCase()) {
1293
+ values = [value];
1294
+
1295
+ if (next != null) {
1296
+ selected = next;
1297
+
1298
+ setAria(next, 'selected', true);
1299
+ }
1300
+ } else {
1301
+ values = [state.autocomplete.none ? input : ''];
1302
+ }
1303
+
1304
+ state.selected = selected == null ? [] : [selected];
1305
+
1306
+ shouldUpdate = state.values[0] !== values[0];
1307
+ }
1308
+
1309
+ if (shouldUpdate) {
1310
+ updateForm(combobox, state, values);
1311
+ }
1312
+
1313
+ if (next == null) {
1314
+ return;
1315
+ }
1316
+
1317
+ if (state.type === 'select') {
1318
+ updateSelect(update, state, next, added);
1319
+ } else if (update !== 'remove') {
1320
+ updateAutoComplete(update, state, next);
1321
+ }
1322
+
1323
+ if (update === 'set' && !state.multiple) {
1324
+ state.floatable.hide();
1325
+ }
1326
+
1327
+ setTimeout(() => {
1328
+ updateWidth(state);
1329
+ });
1330
+ }
1331
+
1332
+ function setValues(combobox: OuiComboboxElement, state: OuiComboboxState, value: unknown): void {
1333
+ if (!Array.isArray(value)) {
1334
+ return;
1335
+ }
1336
+
1337
+ const strings = (state.multiple ? value : [value]).map(value =>
1338
+ getString(value).toLocaleLowerCase(),
1339
+ );
1340
+
1341
+ const [all] = getOptions(state);
1342
+
1343
+ const selected: HTMLElement[] = [];
1344
+ const values: string[] = [];
1345
+
1346
+ let next: HTMLElement | undefined;
1347
+
1348
+ for (const option of all) {
1349
+ const value = getAttribute(option, 'value');
1350
+
1351
+ if (value == null || !strings.includes(value.toLocaleLowerCase())) {
1352
+ continue;
1353
+ }
1354
+
1355
+ selected.push(option);
1356
+ values.push(value);
1357
+
1358
+ next ??= option;
1359
+ }
1360
+
1361
+ for (const option of state.selected) {
1362
+ setAria(option, 'selected', false);
1363
+
1364
+ state.selection?.querySelector(`[option="${option.id}"]`)?.remove();
1365
+ }
1366
+
1367
+ for (const option of selected) {
1368
+ setAria(option, 'selected', true);
1369
+
1370
+ state.selection?.append(getSelectionItem(option));
1371
+ }
1372
+
1373
+ state.selected = selected.slice();
1374
+
1375
+ setNext('navigation', combobox, state, all, next);
1376
+
1377
+ updateForm(combobox, state, values);
1378
+ }
1379
+
1380
+ function updateAutoComplete(
1381
+ update: OuiComboboxUpdate,
1382
+ state: OuiComboboxState,
1383
+ next: HTMLElement,
1384
+ ): void {
1385
+ if (!(update === 'set' || state.autocomplete.both || state.autocomplete.inline)) {
1386
+ return;
1387
+ }
1388
+
1389
+ const content = next.textContent ?? '';
1390
+
1391
+ if (
1392
+ update === 'typed' &&
1393
+ !content.toLocaleLowerCase().startsWith(state.filter.value.toLocaleLowerCase())
1394
+ ) {
1395
+ return;
1396
+ }
1397
+
1398
+ const {length} = content;
1399
+
1400
+ if (state.anchor instanceof HTMLInputElement) {
1401
+ state.anchor.value = content;
1402
+
1403
+ state.anchor.setSelectionRange(update === 'typed' ? state.filter.value.length : length, length);
1404
+ } else {
1405
+ state.anchor.textContent = content;
1406
+ }
1407
+ }
1408
+
1409
+ function updateForm(combobox: OuiComboboxElement, state: OuiComboboxState, values: string[]): void {
1410
+ const formValues = values.length === 0 ? [''] : values;
1411
+
1412
+ const formData = new FormData();
1413
+
1414
+ for (const value of formValues) {
1415
+ formData.append(state.name, value);
1416
+ }
1417
+
1418
+ state.values = formValues;
1419
+
1420
+ state.internals.setFormValue(formData);
1421
+
1422
+ updateValidity(state);
1423
+
1424
+ dispatch(combobox, 'change');
1425
+ }
1426
+
1427
+ function updateSelect(
1428
+ update: OuiComboboxUpdate,
1429
+ state: OuiComboboxState,
1430
+ next: HTMLElement,
1431
+ added?: boolean,
1432
+ ): void {
1433
+ if (update !== 'set') {
1434
+ return;
1435
+ }
1436
+
1437
+ if (!state.multiple) {
1438
+ state.anchor.textContent = next.textContent ?? '';
1439
+
805
1440
  return;
806
1441
  }
807
1442
 
1443
+ if (added === true) {
1444
+ state.selection?.append(getSelectionItem(next));
1445
+ } else if (added === false) {
1446
+ state.selection?.querySelector(`[option="${next.id}"]`)?.remove();
1447
+ }
1448
+ }
1449
+
1450
+ function updateSelected(
1451
+ update: OuiComboboxUpdate,
1452
+ state: OuiComboboxState,
1453
+ next?: HTMLElement,
1454
+ ): boolean | undefined {
808
1455
  const [all] = getOptions(state);
809
1456
 
810
1457
  for (const option of all) {
811
1458
  setAria(option, 'selected', getSelected(update, state, option, next));
812
1459
  }
813
1460
 
814
- state.select.value = next?.getAttribute('value') ?? '';
815
-
816
- if (state.value !== state.select.value) {
817
- state.value = state.select.value;
1461
+ if (next == null) {
1462
+ return;
1463
+ }
818
1464
 
819
- state.internals.setFormValue(state.value);
1465
+ if (state.type !== 'select') {
1466
+ state.selected = [next];
820
1467
 
821
- dispatch(combobox, 'change');
822
- dispatch(state.select, 'change');
1468
+ return;
823
1469
  }
824
1470
 
825
- if (next == null || update === 'none') {
1471
+ if (update !== 'set') {
826
1472
  return;
827
1473
  }
828
1474
 
829
- if (update === 'set' || state.autocomplete.both || state.autocomplete.inline) {
830
- const textContent = next.textContent ?? '';
1475
+ if (!state.multiple) {
1476
+ state.selected = [next];
831
1477
 
832
- if (state.anchor instanceof HTMLInputElement) {
833
- state.anchor.value = textContent;
834
- } else {
835
- state.anchor.innerHTML = textContent;
836
- }
1478
+ return;
1479
+ }
837
1480
 
838
- if (state.anchor instanceof HTMLInputElement) {
839
- state.anchor.setSelectionRange(
840
- update === 'typed' ? state.filter.length : textContent.length,
841
- textContent.length,
842
- );
843
- }
1481
+ const index = state.selected.indexOf(next);
1482
+ const added = index === -1;
1483
+
1484
+ if (added) {
1485
+ state.selected.push(next);
1486
+ } else {
1487
+ state.selected.splice(index, 1);
844
1488
  }
845
1489
 
846
- if (update === 'set') {
847
- state.floatable.hide();
1490
+ return added;
1491
+ }
1492
+
1493
+ function updateValidity(state: OuiComboboxState): void {
1494
+ if (state.autocomplete.none) {
1495
+ const valueMissing = state.required && state.values[0].length === 0;
1496
+
1497
+ state.internals.setValidity(
1498
+ {
1499
+ valueMissing,
1500
+ },
1501
+ INVALID_INPUT,
1502
+ );
1503
+
1504
+ state.internals.checkValidity();
1505
+
1506
+ return;
848
1507
  }
1508
+
1509
+ const rangeOverflow =
1510
+ state.multiple && typeof state.max === 'number' && state.selected.length > state.max;
1511
+
1512
+ const rangeUnderflow =
1513
+ state.multiple && typeof state.min === 'number' && state.selected.length < state.min;
1514
+
1515
+ const badInput = state.required && state.selected.length === 0;
1516
+
1517
+ state.internals.setValidity(
1518
+ {
1519
+ badInput,
1520
+ rangeOverflow,
1521
+ rangeUnderflow,
1522
+ },
1523
+ join(
1524
+ [
1525
+ badInput ? INVALID_BAD : undefined,
1526
+ rangeOverflow
1527
+ ? getString(INVALID_OVERFLOW).replace('{max}', state.max!.toString())
1528
+ : undefined,
1529
+ rangeUnderflow
1530
+ ? getString(INVALID_UNDERFLOW).replace('{min}', state.min!.toString())
1531
+ : undefined,
1532
+ ],
1533
+ '; ',
1534
+ ),
1535
+ );
1536
+
1537
+ state.internals.checkValidity();
1538
+ }
1539
+
1540
+ function updateWidth(state: OuiComboboxState): void {
1541
+ setStyle(state.content, PROPERTY_STYLE, `${state.anchor.getBoundingClientRect().width}px`);
849
1542
  }
850
1543
 
851
1544
  // #endregion
@@ -858,30 +1551,54 @@ const ATTRIBUTE_ANCHOR = `${TAGNAME}-anchor`;
858
1551
 
859
1552
  const ATTRIBUTE_CONTENT = `${TAGNAME}-content`;
860
1553
 
1554
+ const ATTRIBUTE_EMPTY = `${TAGNAME}-empty`;
1555
+
1556
+ const ATTRIBUTE_FILTER = `${TAGNAME}-filter`;
1557
+
1558
+ const ATTRIBUTE_FILTER_INPUT = `${ATTRIBUTE_FILTER}-input`;
1559
+
1560
+ const ATTRIBUTE_FILTER_LABEL = `${ATTRIBUTE_FILTER}-label`;
1561
+
1562
+ const ATTRIBUTE_HEADER = `${TAGNAME}-header`;
1563
+
861
1564
  const ATTRIBUTE_LIST = `${TAGNAME}-list`;
862
1565
 
863
1566
  const ATTRIBUTE_OPTION = `${TAGNAME}-option`;
864
1567
 
865
- const ATTRIBUTE_OPTION_CURRENT = `${TAGNAME}-option-current`;
1568
+ const ATTRIBUTE_OPTION_ACTIVE = `${TAGNAME}-option-active`;
1569
+
1570
+ const ATTRIBUTE_SELECTION = `${TAGNAME}-selection`;
866
1571
 
867
- const ATTRIBUTE_TYPE = 'type';
1572
+ const ATTRIBUTE_SELECTION_ITEM = `${TAGNAME}-selection-item`;
868
1573
 
869
- const AUTOCOMPLETES = new Set<keyof OuiComboboxAutoComplete>(['both', 'inline', 'list', 'none']);
1574
+ const AUTOCOMPLETES = new Set<OuiComboboxAutoCompleteType>(['both', 'inline', 'list', 'none']);
870
1575
 
871
1576
  const EXPRESSION_TYPED = /\p{L}|\s/u;
872
1577
 
873
- const KEYS_ABSOLUTE = new Set(['End', 'Home']);
1578
+ const INVALID_BAD = 'Please select an option from the list';
1579
+
1580
+ const INVALID_INPUT = 'Please enter a value';
1581
+
1582
+ const INVALID_OVERFLOW = 'Please select fewer options (maximum is {max})';
1583
+
1584
+ const INVALID_UNDERFLOW = 'Please select more options (minimum is {min})';
874
1585
 
875
1586
  const KEYS_NAVIGATION = new Set(['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp']);
876
1587
 
877
1588
  const KEYS_REMOVE = new Set(['Backspace', 'Delete']);
878
1589
 
1590
+ const KEYS_SELECTION = new Set(['ArrowRight', 'End']);
1591
+
879
1592
  const MESSAGE_LABEL = `<${TAGNAME}> must contain a <label> element for the <select> element`;
880
1593
 
881
- const MESSAGE_SELECT = `<${TAGNAME}> must contain a <select> element`;
1594
+ const MESSAGE_SELECT = `<${TAGNAME}> must contain a valid <select> element`;
1595
+
1596
+ const PROPERTY_STYLE = '--oui-combobox-width';
882
1597
 
883
1598
  const SELECTOR_ANCHOR = `[${ATTRIBUTE_ANCHOR}]`;
884
1599
 
1600
+ const SELECTOR_FILTER = `[${ATTRIBUTE_FILTER_INPUT}]`;
1601
+
885
1602
  const SELECTOR_OPTION = `[${ATTRIBUTE_OPTION}]`;
886
1603
 
887
1604
  const TYPES = new Set<OuiComboboxType>(['autocomplete', 'select']);