@oscarpalmer/oui 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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') ?? '';
46
86
  }
47
87
 
48
- set name(value: string) {
49
- if (typeof value === 'string') {
50
- this.setAttribute('name', value);
88
+ /**
89
+ * Is the combobox readonly?
90
+ */
91
+ get readonly(): boolean {
92
+ return this.hasAttribute('readonly');
93
+ }
94
+
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,18 +178,31 @@ 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
 
109
208
  type OuiComboboxAutoComplete = Record<OuiComboboxAutoCompleteType, boolean> & {
@@ -118,22 +217,30 @@ type OuiComboboxState = {
118
217
  autocomplete: OuiComboboxAutoComplete;
119
218
  content: HTMLElement;
120
219
  disabled: boolean;
220
+ empty: HTMLElement;
121
221
  filter: string;
122
222
  floatable: OuiFloatable;
123
223
  id: string;
124
224
  internals: ElementInternals;
125
225
  label: HTMLLabelElement;
126
226
  listeners: RemovableEventListener[];
227
+ max?: number;
228
+ min?: number;
229
+ name: string;
127
230
  multiple: boolean;
231
+ readonly: boolean;
128
232
  required: boolean;
129
233
  select: HTMLSelectElement;
130
234
  selected: HTMLElement[];
235
+ touched: boolean;
131
236
  type: OuiComboboxType;
132
- value: string;
237
+ values: string[];
133
238
  };
134
239
 
135
240
  type OuiComboboxType = 'autocomplete' | 'select';
136
241
 
242
+ type OuiComboboxUpdate = 'navigation' | 'none' | 'remove' | 'set' | 'typed';
243
+
137
244
  // #endregion
138
245
 
139
246
  // #region Functions
@@ -164,47 +271,69 @@ function filterOptions(
164
271
  }
165
272
  }
166
273
 
167
- if (visible.length > 0) {
274
+ state.empty.hidden = visible.length > 0;
275
+
276
+ if (state.empty.hidden) {
168
277
  return state.active != null && visible.includes(state.active) ? state.active : visible[0];
169
278
  }
170
279
  }
171
280
 
281
+ function generateIds(select: HTMLSelectElement, prefix: string): void {
282
+ const options = [...select.options];
283
+ const {length} = options;
284
+
285
+ for (let index = 0; index < length; index += 1) {
286
+ const option = options[index];
287
+
288
+ if (option.id === '') {
289
+ option.id = `${prefix}_option_${index}`;
290
+ }
291
+ }
292
+ }
293
+
172
294
  function getAnchor(
173
295
  type: OuiComboboxType,
174
296
  id: string,
175
- element: OuiComboboxElement,
176
297
  label: HTMLLabelElement,
177
298
  select: HTMLSelectElement,
178
299
  autocomplete: OuiComboboxAutoComplete,
300
+ multiple: boolean,
179
301
  ): 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',
302
+ const anchor = type === 'autocomplete' ? getInput(id, select) : getBox(id, select, multiple);
303
+
304
+ setAria(anchor, {
305
+ autocomplete: type === 'autocomplete' ? autocomplete.value : undefined,
306
+ controls: `${id}_content`,
307
+ expanded: false,
308
+ haspopup: 'listbox',
309
+ labelledby: label.id,
194
310
  });
195
311
 
312
+ setAttribute(anchor, ATTRIBUTE_ANCHOR, '');
313
+
314
+ setRole(anchor, 'combobox');
315
+
196
316
  return anchor;
197
317
  }
198
318
 
319
+ function getAttributeValue<Value>(
320
+ element: HTMLElement,
321
+ attribute: string,
322
+ possible: Set<Value>,
323
+ defaultValue: Value,
324
+ ): Value {
325
+ const value = getAttribute(element, attribute);
326
+
327
+ return possible.has(value as Value) ? (value as Value) : defaultValue;
328
+ }
329
+
199
330
  function getAutoComplete(element: OuiComboboxElement, select: boolean): OuiComboboxAutoComplete {
200
331
  let value: OuiComboboxAutoCompleteType;
201
332
 
202
333
  if (select) {
203
334
  value = 'list';
204
335
  } else {
205
- const attribute = getAttribute(element, 'autocomplete') as OuiComboboxAutoCompleteType;
206
-
207
- value = AUTOCOMPLETES.has(attribute) ? attribute : 'none';
336
+ value = getAttributeValue(element, 'autocomplete', AUTOCOMPLETES, 'none');
208
337
  }
209
338
 
210
339
  return {
@@ -216,28 +345,41 @@ function getAutoComplete(element: OuiComboboxElement, select: boolean): OuiCombo
216
345
  };
217
346
  }
218
347
 
219
- function getBox(
220
- id: string,
221
- element: OuiComboboxElement,
222
- label: HTMLLabelElement,
223
- select: HTMLSelectElement,
224
- ): HTMLElement {
225
- return createElement('div', {
348
+ function getBox(id: string, select: HTMLSelectElement, multiple: boolean): HTMLElement {
349
+ const box = createElement('div', {
226
350
  id,
227
- innerHTML: select.selectedOptions[0]?.textContent ?? '',
228
351
  tabIndex: 0,
352
+ textContent: multiple ? '' : (select.selectedOptions[0]?.textContent ?? ''),
229
353
  });
354
+
355
+ if (multiple) {
356
+ const selection = createElement(
357
+ 'ul',
358
+ {
359
+ // tabIndex: 0, TODO: handle navigation
360
+ },
361
+ {
362
+ [ATTRIBUTE_SELECTION]: '',
363
+ },
364
+ );
365
+
366
+ for (const option of select.selectedOptions) {
367
+ selection.append(getSelectionItem(option));
368
+ }
369
+
370
+ box.append(selection);
371
+ }
372
+
373
+ return box;
230
374
  }
231
375
 
232
376
  function getContent(
233
- type: OuiComboboxType,
234
377
  id: string,
235
- element: OuiComboboxElement,
236
- anchor: HTMLElement,
237
378
  label: HTMLLabelElement,
238
379
  select: HTMLSelectElement,
239
380
  autocomplete: OuiComboboxAutoComplete,
240
- ): [HTMLElement, HTMLElement[]] {
381
+ multiple: boolean,
382
+ ): [HTMLElement, HTMLElement[], HTMLElement] {
241
383
  const content = createElement(
242
384
  'div',
243
385
  {
@@ -246,7 +388,6 @@ function getContent(
246
388
  {
247
389
  [ATTRIBUTE_CONTENT]: '',
248
390
  popover: '',
249
- role: 'listbox',
250
391
  tabindex: -1,
251
392
  'aria-labelledby': label.id,
252
393
  },
@@ -254,9 +395,13 @@ function getContent(
254
395
 
255
396
  const list = createElement(
256
397
  'ul',
257
- {},
398
+ {
399
+ role: 'listbox',
400
+ },
258
401
  {
259
402
  [ATTRIBUTE_LIST]: '',
403
+ 'aria-labelledby': label.id,
404
+ 'aria-multiselectable': multiple,
260
405
  },
261
406
  );
262
407
 
@@ -270,12 +415,12 @@ function getContent(
270
415
  const item = createElement(
271
416
  'li',
272
417
  {
273
- id: `${id}_option_${index}`,
418
+ id: option.id,
274
419
  textContent: option.textContent,
275
420
  },
276
421
  {
277
422
  [ATTRIBUTE_OPTION]: '',
278
- [ATTRIBUTE_OPTION_CURRENT]: index === 0 ? '' : undefined,
423
+ [ATTRIBUTE_OPTION_ACTIVE]: index === 0 ? '' : undefined,
279
424
  role: 'option',
280
425
  value: option.value,
281
426
  'aria-selected': !autocomplete.none && option.selected,
@@ -289,9 +434,54 @@ function getContent(
289
434
  list.append(item);
290
435
  }
291
436
 
292
- content.append(list);
437
+ const empty = createElement(
438
+ 'div',
439
+ {
440
+ hidden: true,
441
+ role: 'status',
442
+ textContent: 'No results found',
443
+ },
444
+ {
445
+ [ATTRIBUTE_EMPTY]: '',
446
+ 'aria-live': 'polite',
447
+ },
448
+ );
449
+
450
+ content.append(list, empty);
451
+
452
+ return [content, selected, empty];
453
+ }
454
+
455
+ function getFormValue(state: OuiComboboxState, values: string[]): string {
456
+ if (values.length === 0 || !state.multiple) {
457
+ return values[0];
458
+ }
459
+
460
+ let formValues: string[] = [];
461
+
462
+ for (const value of values) {
463
+ formValues.push(`${state.name}=${value}`);
464
+ }
465
+
466
+ return join(formValues, '&');
467
+ }
468
+
469
+ function getFormValues(state: OuiComboboxState, added?: boolean): [string[], boolean] {
470
+ let values: string[];
471
+
472
+ if (state.multiple) {
473
+ values =
474
+ typeof added === 'boolean'
475
+ ? state.selected.map(option => getAttribute(option, 'value') ?? '')
476
+ : state.values;
477
+ } else {
478
+ values = [getAttribute(state.selected[0], 'value') ?? ''];
479
+ }
480
+
481
+ const previous = getFormValue(state, state.values);
482
+ const next = getFormValue(state, values);
293
483
 
294
- return [content, selected];
484
+ return [values, next !== previous];
295
485
  }
296
486
 
297
487
  function getId(): string {
@@ -300,12 +490,7 @@ function getId(): string {
300
490
  return `oui_combobox_${index}`;
301
491
  }
302
492
 
303
- function getInput(
304
- id: string,
305
- element: OuiComboboxElement,
306
- label: HTMLLabelElement,
307
- select: HTMLSelectElement,
308
- ): HTMLInputElement {
493
+ function getInput(id: string, select: HTMLSelectElement): HTMLInputElement {
309
494
  return createElement(
310
495
  'input',
311
496
  {
@@ -325,15 +510,16 @@ function getOption(
325
510
  value: unknown,
326
511
  ): [HTMLElement | undefined, HTMLElement[]] {
327
512
  const [all] = getOptions(state);
328
- const valueAsString = getString(value);
513
+ const valueAsString = getString(value).toLocaleLowerCase();
329
514
 
330
515
  filterOptions(state, all, true);
331
516
 
332
517
  let match: HTMLElement | undefined;
333
518
 
334
519
  for (const option of all) {
335
- if (option.getAttribute('value')?.toLocaleLowerCase() === valueAsString) {
520
+ if (getAttribute(option, 'value')?.toLocaleLowerCase() === valueAsString) {
336
521
  match = option;
522
+
337
523
  break;
338
524
  }
339
525
  }
@@ -352,7 +538,7 @@ function getOptions(state: OuiComboboxState): HTMLElement[][] {
352
538
  }
353
539
 
354
540
  function getSelected(
355
- update: 'navigation' | 'none' | 'set' | 'typed',
541
+ update: OuiComboboxUpdate,
356
542
  state: OuiComboboxState,
357
543
  element: HTMLElement,
358
544
  next?: HTMLElement,
@@ -361,29 +547,68 @@ function getSelected(
361
547
  return false;
362
548
  }
363
549
 
550
+ const selected = getAria(element, 'selected') === 'true';
551
+
364
552
  if (update === 'navigation') {
365
553
  if (state.autocomplete.both) {
366
554
  return element === next;
367
555
  }
368
556
 
369
- return getAria(element, 'selected') === 'true';
557
+ return selected;
370
558
  }
371
559
 
372
560
  if (update === 'none') {
373
- return getAria(element, 'selected') === 'true';
561
+ return selected;
374
562
  }
375
563
 
376
564
  if (update === 'set') {
565
+ if (next == null) {
566
+ return selected;
567
+ }
568
+
569
+ if (state.multiple) {
570
+ return element === next ? !selected : selected;
571
+ }
572
+
377
573
  return element === next;
378
574
  }
379
575
 
380
- return state.autocomplete.both && element === next;
576
+ return selected;
577
+ }
578
+
579
+ function getSelectionItem(option: HTMLElement): HTMLElement {
580
+ const icon = createElement(
581
+ 'span',
582
+ {
583
+ innerHTML: '&times;',
584
+ },
585
+ {
586
+ 'aria-hidden': true,
587
+ },
588
+ );
589
+
590
+ const label = createElement('span', {
591
+ textContent: option.textContent,
592
+ });
593
+
594
+ const item = createElement(
595
+ 'li',
596
+ {},
597
+ {
598
+ [ATTRIBUTE_SELECTION_ITEM]: '',
599
+ option: option.id,
600
+ },
601
+ );
602
+
603
+ item.append(icon, label);
604
+
605
+ return item;
381
606
  }
382
607
 
383
608
  function getState(element: OuiComboboxElement, internals: ElementInternals): OuiComboboxState {
384
609
  const select = element.querySelector('select');
385
610
 
386
- if (select == null) {
611
+ if (select == null || select.id === '') {
387
612
  throw new Error(MESSAGE_SELECT);
388
613
  }
389
614
 
@@ -396,22 +621,30 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
396
621
  select.hidden = true;
397
622
 
398
623
  element.id = select.id;
399
- element.name = select.name === '' ? select.id : select.name;
624
+
625
+ setAttribute(element, 'name', select.name === '' ? select.id : select.name);
400
626
 
401
627
  const disabled = element.disabled || select.disabled;
402
628
  const multiple = element.multiple || select.multiple;
629
+ const readonly = element.readonly;
403
630
  const required = element.required || select.required;
404
631
 
405
632
  const id = getId();
406
- const type = multiple ? 'select' : getType(element);
633
+ const type = multiple ? 'select' : getAttributeValue(element, 'type', TYPES, 'select');
407
634
 
408
635
  const autocomplete = getAutoComplete(element, type === 'select');
409
636
 
410
637
  label.htmlFor = id;
411
638
  label.id = `${id}_label`;
412
639
 
413
- const anchor = getAnchor(type, id, element, label, select, autocomplete);
414
- const [content, selected] = getContent(type, id, element, anchor, label, select, autocomplete);
640
+ generateIds(select, id);
641
+
642
+ const anchor = getAnchor(type, id, label, select, autocomplete, multiple);
643
+
644
+ const [content, selected, empty] = getContent(id, label, select, autocomplete, multiple);
645
+
646
+ const values =
647
+ selected.length === 0 ? [''] : selected.map(option => getAttribute(option, 'value') ?? '');
415
648
 
416
649
  const floatable = createEmbeddedFloatable(anchor, content, {
417
650
  position: 'below-start',
@@ -430,21 +663,30 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
430
663
  autocomplete,
431
664
  content,
432
665
  disabled,
666
+ empty,
433
667
  floatable,
434
668
  id,
435
669
  internals,
436
670
  label,
437
671
  multiple,
672
+ readonly,
438
673
  required,
439
674
  select,
440
675
  selected,
441
676
  type,
677
+ values,
442
678
  filter: '',
443
679
  listeners: [],
444
- value: '',
680
+ name: element.name,
681
+ touched: false,
445
682
  };
446
683
 
447
684
  state.listeners.push(
685
+ on(anchor, 'blur', () => {
686
+ state.touched = true;
687
+
688
+ updateValidity(state);
689
+ }),
448
690
  on(content, 'click', event => {
449
691
  onContent(event, content);
450
692
  }),
@@ -455,7 +697,7 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
455
697
  });
456
698
 
457
699
  if (floatable.open) {
458
- setStyle(content, '--oui-combobox-width', `${anchor.getBoundingClientRect().width}px`);
700
+ updateWidth(state);
459
701
  }
460
702
  }),
461
703
  );
@@ -465,10 +707,8 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
465
707
  return state;
466
708
  }
467
709
 
468
- function getType(element: OuiComboboxElement): OuiComboboxType {
469
- const type = getAttribute(element, ATTRIBUTE_TYPE) as OuiComboboxType;
470
-
471
- return TYPES.has(type) ? type : 'select';
710
+ function hasTextSelection(element: HTMLElement): element is HTMLInputElement {
711
+ return element instanceof HTMLInputElement && element.selectionStart !== element.selectionEnd;
472
712
  }
473
713
 
474
714
  function onAnchor(
@@ -498,21 +738,34 @@ function onAnchorKeydown(
498
738
  combobox: OuiComboboxElement,
499
739
  state: OuiComboboxState,
500
740
  ): void {
741
+ const isSelect = state.type === 'select';
742
+
501
743
  let preventDefault = true;
502
744
 
503
745
  switch (true) {
746
+ case event.key === ' ' && state.type === 'select':
504
747
  case event.key === 'Enter':
505
748
  onEnter(combobox, state);
506
749
  break;
507
750
 
508
751
  case event.key === 'Escape':
509
- onEscape(combobox, state);
752
+ onEscape(state);
510
753
  break;
511
754
 
512
- case state.type === 'select' && KEYS_ABSOLUTE.has(event.key):
755
+ case isSelect && KEYS_ABSOLUTE.has(event.key):
513
756
  onNavigation(event, combobox, state);
514
757
  break;
515
758
 
759
+ case !isSelect && KEYS_SELECTION.has(event.key): {
760
+ preventDefault = false;
761
+
762
+ if (hasTextSelection(state.anchor)) {
763
+ onSelection(combobox, state);
764
+ }
765
+
766
+ break;
767
+ }
768
+
516
769
  case KEYS_NAVIGATION.has(event.key):
517
770
  onNavigation(event, combobox, state);
518
771
  break;
@@ -539,14 +792,21 @@ function onAnchorKeyup(
539
792
  combobox: OuiComboboxElement,
540
793
  state: OuiComboboxState,
541
794
  ): void {
795
+ const typed = event.key.length === 1 && EXPRESSION_TYPED.test(event.key);
796
+
542
797
  if (state.type === 'select') {
798
+ if (typed) {
799
+ event.preventDefault();
800
+ event.stopPropagation();
801
+
802
+ onScrollTo(event, combobox, state);
803
+ }
804
+
543
805
  return;
544
806
  }
545
807
 
546
808
  let preventDefault = true;
547
809
 
548
- const typed = event.key.length === 1 && EXPRESSION_TYPED.test(event.key);
549
-
550
810
  if (typed) {
551
811
  state.filter += event.key;
552
812
  }
@@ -595,11 +855,23 @@ function onClick(event: PointerEvent): void {
595
855
  if (label != null) {
596
856
  if (label === state.label && combobox.type === 'select') {
597
857
  state.anchor.focus();
858
+
859
+ state.floatable.show();
598
860
  }
599
861
 
600
862
  return;
601
863
  }
602
864
 
865
+ const selection = findAncestor(event, `[${ATTRIBUTE_SELECTION_ITEM}]`);
866
+
867
+ if (selection != null) {
868
+ const option = state.selected.find(option => option.id === getAttribute(selection, 'option'));
869
+
870
+ setValue('set', combobox, state, option);
871
+
872
+ return;
873
+ }
874
+
603
875
  const anchor = findAncestor(event, SELECTOR_ANCHOR);
604
876
 
605
877
  if (anchor != null) {
@@ -620,30 +892,30 @@ function onContent(event: PointerEvent, content: HTMLElement): void {
620
892
  const option = findAncestor(event, SELECTOR_OPTION);
621
893
 
622
894
  if (option instanceof HTMLElement) {
623
- setNext(combobox, state, getOptions(state)[0], 'set', option);
895
+ setNext('set', combobox, state, getOptions(state)[0], option);
624
896
  }
625
897
 
626
898
  state.anchor.focus();
627
899
  }
628
900
 
629
901
  function onEnter(combobox: OuiComboboxElement, state: OuiComboboxState): void {
630
- if (state.floatable.open) {
631
- setNext(combobox, state, getOptions(state)[0], 'set', state.active);
632
- } else {
633
- state.floatable.show();
902
+ if (hasTextSelection(state.anchor)) {
903
+ onSelection(combobox, state);
904
+
905
+ return;
634
906
  }
635
- }
636
907
 
637
- function onEscape(combobox: OuiComboboxElement, state: OuiComboboxState): void {
638
908
  if (!state.floatable.open) {
909
+ state.floatable.show();
910
+
639
911
  return;
640
912
  }
641
913
 
642
- if (state.type === 'select') {
643
- state.floatable.hide();
644
- } else {
645
- setNext(combobox, state, getOptions(state)[0], 'set', state.active);
646
- }
914
+ setNext('set', combobox, state, getOptions(state)[0], state.active);
915
+ }
916
+
917
+ function onEscape(state: OuiComboboxState): void {
918
+ state.floatable.hide();
647
919
  }
648
920
 
649
921
  function onKeydown(event: KeyboardEvent): void {
@@ -675,8 +947,8 @@ function onNavigation(
675
947
 
676
948
  let next: HTMLElement | undefined;
677
949
 
678
- if (skip ?? state.active == null) {
679
- next = state.active ?? visible[0];
950
+ if (skip || state.active == null) {
951
+ next = state.active ?? visible.at(event.key === 'ArrowDown' ? 0 : -1);
680
952
  } else {
681
953
  const current = visible.findIndex(option => option.id === state.active?.id);
682
954
 
@@ -710,7 +982,7 @@ function onNavigation(
710
982
  }
711
983
  }
712
984
 
713
- setNext(combobox, state, all, state.autocomplete.none ? 'none' : 'navigation', next);
985
+ setNext(state.autocomplete.none ? 'none' : 'navigation', combobox, state, all, next);
714
986
  }
715
987
 
716
988
  function onRemove(combobox: OuiComboboxElement, state: OuiComboboxState): void {
@@ -718,7 +990,33 @@ function onRemove(combobox: OuiComboboxElement, state: OuiComboboxState): void {
718
990
 
719
991
  const next = filterOptions(state, all);
720
992
 
721
- setNext(combobox, state, all, 'none', next);
993
+ setNext('remove', combobox, state, all, next);
994
+ }
995
+
996
+ function onScrollTo(
997
+ event: KeyboardEvent,
998
+ combobox: OuiComboboxElement,
999
+ state: OuiComboboxState,
1000
+ ): void {
1001
+ const [all] = getOptions(state);
1002
+
1003
+ const key = event.key.toLocaleLowerCase();
1004
+
1005
+ const next =
1006
+ all.find(option => option.textContent?.toLocaleLowerCase().startsWith(key) === true) ??
1007
+ all.find(option => option.textContent?.toLocaleLowerCase().includes(key) === true);
1008
+
1009
+ if (next != null) {
1010
+ setNext('navigation', combobox, state, all, next);
1011
+ }
1012
+ }
1013
+
1014
+ function onSelection(combobox: OuiComboboxElement, state: OuiComboboxState): void {
1015
+ state.filter = state.anchor instanceof HTMLInputElement ? state.anchor.value : '';
1016
+
1017
+ filterOptions(state);
1018
+
1019
+ setNext('set', combobox, state, getOptions(state)[0], state.active);
722
1020
  }
723
1021
 
724
1022
  function onTab(combobox: OuiComboboxElement, state: OuiComboboxState): void {
@@ -728,33 +1026,19 @@ function onTab(combobox: OuiComboboxElement, state: OuiComboboxState): void {
728
1026
  }
729
1027
 
730
1028
  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
- }
1029
+ if (
1030
+ !state.autocomplete.inline &&
1031
+ state.anchor instanceof HTMLInputElement &&
1032
+ state.anchor.value.length > 0
1033
+ ) {
1034
+ state.floatable.show();
739
1035
  }
740
1036
 
741
- if (!state.autocomplete.none) {
742
- const next = filterOptions(state);
1037
+ const options = getOptions(state)[0];
743
1038
 
744
- state.active =
745
- next != null &&
746
- next.textContent?.toLocaleLowerCase().indexOf(state.filter.toLocaleLowerCase()) === 0
747
- ? next
748
- : undefined;
1039
+ const next = state.autocomplete.none ? (state.active ?? options[0]) : filterOptions(state);
749
1040
 
750
- if (next != null) {
751
- open();
752
- }
753
-
754
- setNext(combobox, state, getOptions(state)[0], 'typed', next);
755
- } else {
756
- open();
757
- }
1041
+ setNext('typed', combobox, state, options, next);
758
1042
  }
759
1043
 
760
1044
  function setDisabled(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
@@ -768,15 +1052,33 @@ function setDisabled(combobox: OuiComboboxElement, state: OuiComboboxState, valu
768
1052
  }
769
1053
  }
770
1054
 
1055
+ function setLimit(
1056
+ type: 'max' | 'min',
1057
+ combobox: OuiComboboxElement,
1058
+ state: OuiComboboxState,
1059
+ value?: number,
1060
+ ): void {
1061
+ const updateLimit =
1062
+ typeof value === 'number' && !Number.isNaN(value) ? value !== state[type] : value == null;
1063
+
1064
+ if (updateLimit) {
1065
+ state[type] = value;
1066
+
1067
+ setAttribute(combobox, type, value);
1068
+
1069
+ updateValidity(state);
1070
+ }
1071
+ }
1072
+
771
1073
  function setNext(
1074
+ update: OuiComboboxUpdate,
772
1075
  combobox: OuiComboboxElement,
773
1076
  state: OuiComboboxState,
774
1077
  all: HTMLElement[],
775
- update: 'navigation' | 'none' | 'set' | 'typed',
776
1078
  next?: HTMLElement,
777
1079
  ): void {
778
1080
  for (const option of all) {
779
- setAttribute(option, ATTRIBUTE_OPTION_CURRENT, option === next ? '' : undefined);
1081
+ setAttribute(option, ATTRIBUTE_OPTION_ACTIVE, option === next ? '' : undefined);
780
1082
  }
781
1083
 
782
1084
  state.active = next;
@@ -785,7 +1087,18 @@ function setNext(
785
1087
 
786
1088
  next?.scrollIntoView(scrollOptions);
787
1089
 
788
- setValue(combobox, state, update, next);
1090
+ setValue(update, combobox, state, next);
1091
+ }
1092
+
1093
+ function setReadonly(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1094
+ state.readonly = value;
1095
+
1096
+ setAria(state.anchor, 'readonly', value);
1097
+ setProperty(combobox, 'readonly', value);
1098
+
1099
+ if (state.anchor instanceof HTMLInputElement) {
1100
+ state.anchor.readOnly = value;
1101
+ }
789
1102
  }
790
1103
 
791
1104
  function setRequired(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
@@ -793,59 +1106,292 @@ function setRequired(combobox: OuiComboboxElement, state: OuiComboboxState, valu
793
1106
 
794
1107
  setAria(state.anchor, 'required', value);
795
1108
  setProperty(combobox, 'required', value);
1109
+
1110
+ updateValidity(state);
796
1111
  }
797
1112
 
798
1113
  function setValue(
1114
+ update: OuiComboboxUpdate,
799
1115
  combobox: OuiComboboxElement,
800
1116
  state: OuiComboboxState,
801
- update: 'navigation' | 'none' | 'set' | 'typed',
802
1117
  next?: HTMLElement,
803
1118
  ): void {
804
- if (state.disabled) {
1119
+ if (state.disabled || state.readonly || (state.type === 'select' && update !== 'set')) {
805
1120
  return;
806
1121
  }
807
1122
 
1123
+ let added: boolean | undefined;
1124
+ let shouldUpdate: boolean;
1125
+ let values: string[];
1126
+
1127
+ if (update === 'set' || !(state.anchor instanceof HTMLInputElement)) {
1128
+ added = updateSelected(update, state, next);
1129
+
1130
+ [values, shouldUpdate] = getFormValues(state, added);
1131
+ } else {
1132
+ const [all] = getOptions(state);
1133
+
1134
+ for (const option of all) {
1135
+ setAria(option, 'selected', false);
1136
+ }
1137
+
1138
+ const input = state.anchor.value;
1139
+
1140
+ const content = next?.textContent ?? '';
1141
+ const value = next == null ? '' : (getAttribute(next, 'value') ?? '');
1142
+
1143
+ let selected: HTMLElement | undefined;
1144
+
1145
+ if (content.toLocaleLowerCase() === input.toLocaleLowerCase()) {
1146
+ values = [value];
1147
+
1148
+ if (next != null) {
1149
+ selected = next;
1150
+
1151
+ setAria(next, 'selected', true);
1152
+ }
1153
+ } else {
1154
+ values = [state.autocomplete.none ? input : ''];
1155
+ }
1156
+
1157
+ state.selected = selected == null ? [] : [selected];
1158
+
1159
+ shouldUpdate = state.values[0] !== values[0];
1160
+ }
1161
+
1162
+ if (shouldUpdate) {
1163
+ updateForm(combobox, state, values);
1164
+ }
1165
+
1166
+ if (next == null) {
1167
+ return;
1168
+ }
1169
+
1170
+ if (state.type === 'select') {
1171
+ updateSelect(update, state, next, added);
1172
+ } else if (update !== 'remove') {
1173
+ updateAutoComplete(update, state, next);
1174
+ }
1175
+
1176
+ if (update === 'set' && !state.multiple) {
1177
+ state.floatable.hide();
1178
+ }
1179
+
1180
+ setTimeout(() => {
1181
+ updateWidth(state);
1182
+ });
1183
+ }
1184
+
1185
+ function setValues(combobox: OuiComboboxElement, state: OuiComboboxState, value: unknown): void {
1186
+ if (!Array.isArray(value)) {
1187
+ return;
1188
+ }
1189
+
1190
+ const strings = (state.multiple ? value : [value]).map(value =>
1191
+ getString(value).toLocaleLowerCase(),
1192
+ );
1193
+
808
1194
  const [all] = getOptions(state);
809
1195
 
1196
+ const selected: HTMLElement[] = [];
1197
+ const values: string[] = [];
1198
+
1199
+ let next: HTMLElement | undefined;
1200
+
810
1201
  for (const option of all) {
811
- setAria(option, 'selected', getSelected(update, state, option, next));
1202
+ const value = getAttribute(option, 'value');
1203
+
1204
+ if (value == null || !strings.includes(value.toLocaleLowerCase())) {
1205
+ continue;
1206
+ }
1207
+
1208
+ selected.push(option);
1209
+ values.push(value);
1210
+
1211
+ next ??= option;
812
1212
  }
813
1213
 
814
- state.select.value = next?.getAttribute('value') ?? '';
1214
+ for (const option of state.selected) {
1215
+ setAria(option, 'selected', false);
815
1216
 
816
- if (state.value !== state.select.value) {
817
- state.value = state.select.value;
1217
+ state.anchor.querySelector(`[${ATTRIBUTE_SELECTION_ITEM}][option="${option.id}"]`)?.remove();
1218
+ }
818
1219
 
819
- state.internals.setFormValue(state.value);
1220
+ for (const option of selected) {
1221
+ setAria(option, 'selected', true);
820
1222
 
821
- dispatch(combobox, 'change');
822
- dispatch(state.select, 'change');
1223
+ state.anchor.querySelector(`[${ATTRIBUTE_SELECTION}]`)?.append(getSelectionItem(option));
823
1224
  }
824
1225
 
825
- if (next == null || update === 'none') {
1226
+ state.selected = selected.slice();
1227
+
1228
+ setNext('navigation', combobox, state, all, next);
1229
+
1230
+ updateForm(combobox, state, values);
1231
+ }
1232
+
1233
+ function updateAutoComplete(
1234
+ update: OuiComboboxUpdate,
1235
+ state: OuiComboboxState,
1236
+ next: HTMLElement,
1237
+ ): void {
1238
+ if (!(update === 'set' || state.autocomplete.both || state.autocomplete.inline)) {
826
1239
  return;
827
1240
  }
828
1241
 
829
- if (update === 'set' || state.autocomplete.both || state.autocomplete.inline) {
830
- const textContent = next.textContent ?? '';
1242
+ const content = next.textContent ?? '';
831
1243
 
832
- if (state.anchor instanceof HTMLInputElement) {
833
- state.anchor.value = textContent;
834
- } else {
835
- state.anchor.innerHTML = textContent;
836
- }
1244
+ if (
1245
+ update === 'typed' &&
1246
+ !content.toLocaleLowerCase().startsWith(state.filter.toLocaleLowerCase())
1247
+ ) {
1248
+ return;
1249
+ }
837
1250
 
838
- if (state.anchor instanceof HTMLInputElement) {
839
- state.anchor.setSelectionRange(
840
- update === 'typed' ? state.filter.length : textContent.length,
841
- textContent.length,
842
- );
843
- }
1251
+ const {length} = content;
1252
+
1253
+ if (state.anchor instanceof HTMLInputElement) {
1254
+ state.anchor.value = content;
1255
+
1256
+ state.anchor.setSelectionRange(update === 'typed' ? state.filter.length : length, length);
1257
+ } else {
1258
+ state.anchor.textContent = content;
844
1259
  }
1260
+ }
845
1261
 
846
- if (update === 'set') {
847
- state.floatable.hide();
1262
+ function updateForm(combobox: OuiComboboxElement, state: OuiComboboxState, values: string[]): void {
1263
+ const formValues = values.length === 0 ? [''] : values;
1264
+
1265
+ const formData = new FormData();
1266
+
1267
+ for (const value of formValues) {
1268
+ formData.append(state.name, value);
1269
+ }
1270
+
1271
+ state.values = formValues;
1272
+
1273
+ state.internals.setFormValue(formData);
1274
+
1275
+ updateValidity(state);
1276
+
1277
+ dispatch(combobox, 'change');
1278
+ }
1279
+
1280
+ function updateSelect(
1281
+ update: OuiComboboxUpdate,
1282
+ state: OuiComboboxState,
1283
+ next: HTMLElement,
1284
+ added?: boolean,
1285
+ ): void {
1286
+ if (update !== 'set') {
1287
+ return;
1288
+ }
1289
+
1290
+ if (!state.multiple) {
1291
+ state.anchor.textContent = next.textContent ?? '';
1292
+
1293
+ return;
1294
+ }
1295
+
1296
+ if (added === true) {
1297
+ state.anchor.querySelector(`[${ATTRIBUTE_SELECTION}]`)?.append(getSelectionItem(next));
1298
+ } else if (added === false) {
1299
+ state.anchor.querySelector(`[${ATTRIBUTE_SELECTION_ITEM}][option="${next.id}"]`)?.remove();
1300
+ }
1301
+ }
1302
+
1303
+ function updateSelected(
1304
+ update: OuiComboboxUpdate,
1305
+ state: OuiComboboxState,
1306
+ next?: HTMLElement,
1307
+ ): boolean | undefined {
1308
+ const [all] = getOptions(state);
1309
+
1310
+ for (const option of all) {
1311
+ setAria(option, 'selected', getSelected(update, state, option, next));
1312
+ }
1313
+
1314
+ if (next == null) {
1315
+ return;
1316
+ }
1317
+
1318
+ if (state.type !== 'select') {
1319
+ state.selected = [next];
1320
+
1321
+ return;
1322
+ }
1323
+
1324
+ if (update !== 'set') {
1325
+ return;
1326
+ }
1327
+
1328
+ if (!state.multiple) {
1329
+ state.selected = [next];
1330
+
1331
+ return;
1332
+ }
1333
+
1334
+ const index = state.selected.indexOf(next);
1335
+ const added = index === -1;
1336
+
1337
+ if (added) {
1338
+ state.selected.push(next);
1339
+ } else {
1340
+ state.selected.splice(index, 1);
1341
+ }
1342
+
1343
+ return added;
1344
+ }
1345
+
1346
+ function updateValidity(state: OuiComboboxState): void {
1347
+ if (state.autocomplete.none) {
1348
+ const valueMissing = state.required && state.values[0].length === 0;
1349
+
1350
+ state.internals.setValidity(
1351
+ {
1352
+ valueMissing,
1353
+ },
1354
+ INVALID_INPUT,
1355
+ );
1356
+
1357
+ state.internals.checkValidity();
1358
+
1359
+ return;
848
1360
  }
1361
+
1362
+ const rangeOverflow =
1363
+ state.multiple && typeof state.max === 'number' && state.selected.length > state.max;
1364
+
1365
+ const rangeUnderflow =
1366
+ state.multiple && typeof state.min === 'number' && state.selected.length < state.min;
1367
+
1368
+ const badInput = state.required && state.selected.length === 0;
1369
+
1370
+ state.internals.setValidity(
1371
+ {
1372
+ badInput,
1373
+ rangeOverflow,
1374
+ rangeUnderflow,
1375
+ },
1376
+ join(
1377
+ [
1378
+ badInput ? INVALID_BAD : undefined,
1379
+ rangeOverflow
1380
+ ? getString(INVALID_OVERFLOW).replace('{max}', state.max!.toString())
1381
+ : undefined,
1382
+ rangeUnderflow
1383
+ ? getString(INVALID_UNDERFLOW).replace('{min}', state.min!.toString())
1384
+ : undefined,
1385
+ ],
1386
+ '; ',
1387
+ ),
1388
+ );
1389
+
1390
+ state.internals.checkValidity();
1391
+ }
1392
+
1393
+ function updateWidth(state: OuiComboboxState): void {
1394
+ setStyle(state.content, PROPERTY_STYLE, `${state.anchor.getBoundingClientRect().width}px`);
849
1395
  }
850
1396
 
851
1397
  // #endregion
@@ -858,27 +1404,43 @@ const ATTRIBUTE_ANCHOR = `${TAGNAME}-anchor`;
858
1404
 
859
1405
  const ATTRIBUTE_CONTENT = `${TAGNAME}-content`;
860
1406
 
1407
+ const ATTRIBUTE_EMPTY = `${TAGNAME}-empty`;
1408
+
861
1409
  const ATTRIBUTE_LIST = `${TAGNAME}-list`;
862
1410
 
863
1411
  const ATTRIBUTE_OPTION = `${TAGNAME}-option`;
864
1412
 
865
- const ATTRIBUTE_OPTION_CURRENT = `${TAGNAME}-option-current`;
1413
+ const ATTRIBUTE_OPTION_ACTIVE = `${TAGNAME}-option-active`;
866
1414
 
867
- const ATTRIBUTE_TYPE = 'type';
1415
+ const ATTRIBUTE_SELECTION = `${TAGNAME}-selection`;
868
1416
 
869
- const AUTOCOMPLETES = new Set<keyof OuiComboboxAutoComplete>(['both', 'inline', 'list', 'none']);
1417
+ const ATTRIBUTE_SELECTION_ITEM = `${TAGNAME}-selection-item`;
1418
+
1419
+ const AUTOCOMPLETES = new Set<OuiComboboxAutoCompleteType>(['both', 'inline', 'list', 'none']);
870
1420
 
871
1421
  const EXPRESSION_TYPED = /\p{L}|\s/u;
872
1422
 
1423
+ const INVALID_BAD = 'Please select an option from the list';
1424
+
1425
+ const INVALID_INPUT = 'Please enter a value';
1426
+
1427
+ const INVALID_OVERFLOW = 'Please select fewer options (maximum is {max})';
1428
+
1429
+ const INVALID_UNDERFLOW = 'Please select more options (minimum is {min})';
1430
+
873
1431
  const KEYS_ABSOLUTE = new Set(['End', 'Home']);
874
1432
 
875
1433
  const KEYS_NAVIGATION = new Set(['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp']);
876
1434
 
877
1435
  const KEYS_REMOVE = new Set(['Backspace', 'Delete']);
878
1436
 
1437
+ const KEYS_SELECTION = new Set(['ArrowRight', 'End']);
1438
+
879
1439
  const MESSAGE_LABEL = `<${TAGNAME}> must contain a <label> element for the <select> element`;
880
1440
 
881
- const MESSAGE_SELECT = `<${TAGNAME}> must contain a <select> element`;
1441
+ const MESSAGE_SELECT = `<${TAGNAME}> must contain a valid <select> element`;
1442
+
1443
+ const PROPERTY_STYLE = '--oui-combobox-width';
882
1444
 
883
1445
  const SELECTOR_ANCHOR = `[${ATTRIBUTE_ANCHOR}]`;
884
1446