@oscarpalmer/oui 0.31.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.
@@ -205,6 +205,11 @@ export class OuiComboboxElement extends HTMLElement {
205
205
  }
206
206
  }
207
207
 
208
+ type OuiComboboxFilter = {
209
+ element?: HTMLInputElement;
210
+ value: string;
211
+ };
212
+
208
213
  type OuiComboboxAutoComplete = Record<OuiComboboxAutoCompleteType, boolean> & {
209
214
  value: OuiComboboxAutoCompleteType;
210
215
  };
@@ -218,8 +223,9 @@ type OuiComboboxState = {
218
223
  content: HTMLElement;
219
224
  disabled: boolean;
220
225
  empty: HTMLElement;
221
- filter: string;
226
+ filter: OuiComboboxFilter;
222
227
  floatable: OuiFloatable;
228
+ headers?: HTMLElement[];
223
229
  id: string;
224
230
  internals: ElementInternals;
225
231
  label: HTMLLabelElement;
@@ -232,6 +238,7 @@ type OuiComboboxState = {
232
238
  required: boolean;
233
239
  select: HTMLSelectElement;
234
240
  selected: HTMLElement[];
241
+ selection?: HTMLElement;
235
242
  touched: boolean;
236
243
  type: OuiComboboxType;
237
244
  values: string[];
@@ -245,61 +252,16 @@ type OuiComboboxUpdate = 'navigation' | 'none' | 'remove' | 'set' | 'typed';
245
252
 
246
253
  // #region Functions
247
254
 
248
- function filterOptions(
249
- state: OuiComboboxState,
250
- options?: HTMLElement[],
251
- clear?: boolean,
252
- ): HTMLElement | undefined {
253
- if (clear || state.autocomplete.none) {
254
- state.filter = '';
255
- }
256
-
257
- const filter = state.filter.toLocaleLowerCase();
258
-
259
- const all = options ?? getOptions(state)[0];
260
- const {length} = all;
261
-
262
- const visible: HTMLElement[] = [];
263
-
264
- for (let index = 0; index < length; index += 1) {
265
- const option = all[index];
266
-
267
- option.hidden = option.textContent?.toLocaleLowerCase().includes(filter) === false;
268
-
269
- if (!option.hidden) {
270
- visible.push(option);
271
- }
272
- }
273
-
274
- state.empty.hidden = visible.length > 0;
275
-
276
- if (state.empty.hidden) {
277
- return state.active != null && visible.includes(state.active) ? state.active : visible[0];
278
- }
279
- }
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
-
294
- function getAnchor(
255
+ function createAnchor(
295
256
  type: OuiComboboxType,
296
257
  id: string,
297
258
  label: HTMLLabelElement,
298
259
  select: HTMLSelectElement,
299
260
  autocomplete: OuiComboboxAutoComplete,
300
261
  multiple: boolean,
301
- ): HTMLElement {
302
- const anchor = type === 'autocomplete' ? getInput(id, select) : getBox(id, select, multiple);
262
+ ): [HTMLElement, HTMLElement | undefined] {
263
+ const [anchor, selection] =
264
+ type === 'autocomplete' ? [createInput(id, select)] : createBox(id, select, multiple);
303
265
 
304
266
  setAria(anchor, {
305
267
  autocomplete: type === 'autocomplete' ? autocomplete.value : undefined,
@@ -310,76 +272,47 @@ function getAnchor(
310
272
  });
311
273
 
312
274
  setAttribute(anchor, ATTRIBUTE_ANCHOR, '');
313
-
314
275
  setRole(anchor, 'combobox');
315
276
 
316
- return anchor;
277
+ return [anchor, selection];
317
278
  }
318
279
 
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
-
330
- function getAutoComplete(element: OuiComboboxElement, select: boolean): OuiComboboxAutoComplete {
331
- let value: OuiComboboxAutoCompleteType;
332
-
333
- if (select) {
334
- value = 'list';
335
- } else {
336
- value = getAttributeValue(element, 'autocomplete', AUTOCOMPLETES, 'none');
337
- }
338
-
339
- return {
340
- value,
341
- both: value === 'both',
342
- inline: value === 'inline',
343
- list: value === 'list',
344
- none: value === 'none',
345
- };
346
- }
347
-
348
- function getBox(id: string, select: HTMLSelectElement, multiple: boolean): HTMLElement {
280
+ function createBox(
281
+ id: string,
282
+ select: HTMLSelectElement,
283
+ multiple: boolean,
284
+ ): [HTMLElement, HTMLElement | undefined] {
349
285
  const box = createElement('div', {
350
286
  id,
351
287
  tabIndex: 0,
352
288
  textContent: multiple ? '' : (select.selectedOptions[0]?.textContent ?? ''),
353
289
  });
354
290
 
355
- if (multiple) {
356
- const selection = createElement(
357
- 'ul',
358
- {
359
- // tabIndex: 0, TODO: handle navigation
360
- },
361
- {
362
- [ATTRIBUTE_SELECTION]: '',
363
- },
364
- );
291
+ let selection: HTMLElement | undefined;
365
292
 
366
- for (const option of select.selectedOptions) {
367
- selection.append(getSelectionItem(option));
368
- }
293
+ if (multiple) {
294
+ selection = createSelection(select);
369
295
 
370
296
  box.append(selection);
371
297
  }
372
298
 
373
- return box;
299
+ return [box, selection];
374
300
  }
375
301
 
376
- function getContent(
302
+ function createContent(
303
+ type: OuiComboboxType,
377
304
  id: string,
378
305
  label: HTMLLabelElement,
379
306
  select: HTMLSelectElement,
380
307
  autocomplete: OuiComboboxAutoComplete,
381
308
  multiple: boolean,
382
- ): [HTMLElement, HTMLElement[], HTMLElement] {
309
+ ): [
310
+ HTMLElement,
311
+ HTMLInputElement | undefined,
312
+ HTMLElement[] | undefined,
313
+ HTMLElement[],
314
+ HTMLElement,
315
+ ] {
383
316
  const content = createElement(
384
317
  'div',
385
318
  {
@@ -393,6 +326,111 @@ function getContent(
393
326
  },
394
327
  );
395
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[]] {
396
434
  const list = createElement(
397
435
  'ul',
398
436
  {
@@ -405,27 +443,79 @@ function getContent(
405
443
  },
406
444
  );
407
445
 
408
- const options = [...select.options];
446
+ const groups = [...select.querySelectorAll('optgroup')];
447
+ const groupsLength = groups.length;
409
448
 
410
449
  const selected: HTMLElement[] = [];
411
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 {
412
515
  for (let index = 0; index < options.length; index += 1) {
413
516
  const option = options[index];
414
517
 
415
- const item = createElement(
416
- 'li',
417
- {
418
- id: option.id,
419
- textContent: option.textContent,
420
- },
421
- {
422
- [ATTRIBUTE_OPTION]: '',
423
- [ATTRIBUTE_OPTION_ACTIVE]: index === 0 ? '' : undefined,
424
- role: 'option',
425
- value: option.value,
426
- 'aria-selected': !autocomplete.none && option.selected,
427
- },
428
- );
518
+ const item = createOption(index, option, autocomplete, active, group);
429
519
 
430
520
  if (option.selected) {
431
521
  selected.push(item);
@@ -433,23 +523,118 @@ function getContent(
433
523
 
434
524
  list.append(item);
435
525
  }
526
+ }
436
527
 
437
- const empty = createElement(
438
- 'div',
439
- {
440
- hidden: true,
441
- role: 'status',
442
- textContent: 'No results found',
443
- },
528
+ function createSelection(select: HTMLSelectElement): HTMLElement {
529
+ const selection = createElement(
530
+ 'ul',
531
+ {},
444
532
  {
445
- [ATTRIBUTE_EMPTY]: '',
446
- 'aria-live': 'polite',
533
+ [ATTRIBUTE_SELECTION]: '',
447
534
  },
448
535
  );
449
536
 
450
- content.append(list, empty);
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];
451
571
 
452
- return [content, selected, empty];
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
+ };
453
638
  }
454
639
 
455
640
  function getFormValue(state: OuiComboboxState, values: string[]): string {
@@ -490,21 +675,6 @@ function getId(): string {
490
675
  return `oui_combobox_${index}`;
491
676
  }
492
677
 
493
- function getInput(id: string, select: HTMLSelectElement): HTMLInputElement {
494
- return createElement(
495
- 'input',
496
- {
497
- id,
498
- value: select.selectedOptions[0]?.textContent ?? '',
499
- },
500
- {
501
- [ATTRIBUTE_ANCHOR]: '',
502
- autocomplete: 'off',
503
- type: 'text',
504
- },
505
- );
506
- }
507
-
508
678
  function getOption(
509
679
  state: OuiComboboxState,
510
680
  value: unknown,
@@ -639,9 +809,16 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
639
809
 
640
810
  generateIds(select, id);
641
811
 
642
- const anchor = getAnchor(type, id, label, select, autocomplete, multiple);
812
+ const [anchor, selection] = createAnchor(type, id, label, select, autocomplete, multiple);
643
813
 
644
- const [content, selected, empty] = getContent(id, label, select, autocomplete, multiple);
814
+ const [content, filter, headers, selected, empty] = createContent(
815
+ type,
816
+ id,
817
+ label,
818
+ select,
819
+ autocomplete,
820
+ multiple,
821
+ );
645
822
 
646
823
  const values =
647
824
  selected.length === 0 ? [''] : selected.map(option => getAttribute(option, 'value') ?? '');
@@ -665,6 +842,7 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
665
842
  disabled,
666
843
  empty,
667
844
  floatable,
845
+ headers,
668
846
  id,
669
847
  internals,
670
848
  label,
@@ -673,16 +851,21 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
673
851
  required,
674
852
  select,
675
853
  selected,
854
+ selection,
676
855
  type,
677
856
  values,
678
- filter: '',
857
+ active: selected[0],
858
+ filter: {
859
+ element: filter,
860
+ value: '',
861
+ },
679
862
  listeners: [],
680
863
  name: element.name,
681
864
  touched: false,
682
865
  };
683
866
 
684
867
  state.listeners.push(
685
- on(anchor, 'blur', () => {
868
+ on(element, 'focusout', () => {
686
869
  state.touched = true;
687
870
 
688
871
  updateValidity(state);
@@ -697,12 +880,18 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
697
880
  });
698
881
 
699
882
  if (floatable.open) {
883
+ state.filter.element?.focus();
884
+
700
885
  updateWidth(state);
701
886
  }
702
887
  }),
703
888
  );
704
889
 
705
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);
706
895
 
707
896
  return state;
708
897
  }
@@ -711,11 +900,87 @@ function hasTextSelection(element: HTMLElement): element is HTMLInputElement {
711
900
  return element instanceof HTMLInputElement && element.selectionStart !== element.selectionEnd;
712
901
  }
713
902
 
714
- function onAnchor(
903
+ function onClick(event: PointerEvent): void {
904
+ const combobox = findAncestor(event, TAGNAME);
905
+ const state = states.get(combobox!);
906
+
907
+ if (combobox == null || state == null) {
908
+ return;
909
+ }
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
+
933
+ const anchor = findAncestor(event, SELECTOR_ANCHOR);
934
+
935
+ if (anchor != null) {
936
+ state.floatable.show();
937
+
938
+ return;
939
+ }
940
+ }
941
+
942
+ function onContent(event: PointerEvent, content: HTMLElement): void {
943
+ const combobox = comboboxes.get(content);
944
+ const state = states.get(content);
945
+
946
+ if (combobox == null || state == null) {
947
+ return;
948
+ }
949
+
950
+ const option = findAncestor(event, SELECTOR_OPTION);
951
+
952
+ if (option instanceof HTMLElement) {
953
+ setNext('set', combobox, state, getOptions(state)[0], option);
954
+ }
955
+
956
+ (state.filter.element ?? state.anchor).focus();
957
+ }
958
+
959
+ function onEnter(combobox: OuiComboboxElement, state: OuiComboboxState): void {
960
+ if (hasTextSelection(state.anchor)) {
961
+ onSelection(combobox, state);
962
+
963
+ return;
964
+ }
965
+
966
+ if (!state.floatable.open) {
967
+ state.floatable.show();
968
+
969
+ return;
970
+ }
971
+
972
+ setNext('set', combobox, state, getOptions(state)[0], state.active);
973
+ }
974
+
975
+ function onEscape(state: OuiComboboxState): void {
976
+ state.floatable.hide();
977
+ }
978
+
979
+ function onInput(
715
980
  event: KeyboardEvent,
716
981
  handler: (event: KeyboardEvent, combobox: OuiComboboxElement, state: OuiComboboxState) => void,
717
982
  ): void {
718
- if (event.ctrlKey || event.metaKey || event.shiftKey) {
983
+ if (event.ctrlKey || event.metaKey) {
719
984
  return;
720
985
  }
721
986
 
@@ -726,14 +991,14 @@ function onAnchor(
726
991
  return;
727
992
  }
728
993
 
729
- const anchor = findAncestor(event, SELECTOR_ANCHOR);
994
+ const input = findAncestor(event, SELECTOR_ANCHOR) ?? findAncestor(event, SELECTOR_FILTER);
730
995
 
731
- if (anchor != null) {
996
+ if (input != null) {
732
997
  handler(event, combobox, state);
733
998
  }
734
999
  }
735
1000
 
736
- function onAnchorKeydown(
1001
+ function onInputKeydown(
737
1002
  event: KeyboardEvent,
738
1003
  combobox: OuiComboboxElement,
739
1004
  state: OuiComboboxState,
@@ -743,7 +1008,6 @@ function onAnchorKeydown(
743
1008
  let preventDefault = true;
744
1009
 
745
1010
  switch (true) {
746
- case event.key === ' ' && state.type === 'select':
747
1011
  case event.key === 'Enter':
748
1012
  onEnter(combobox, state);
749
1013
  break;
@@ -752,10 +1016,6 @@ function onAnchorKeydown(
752
1016
  onEscape(state);
753
1017
  break;
754
1018
 
755
- case isSelect && KEYS_ABSOLUTE.has(event.key):
756
- onNavigation(event, combobox, state);
757
- break;
758
-
759
1019
  case !isSelect && KEYS_SELECTION.has(event.key): {
760
1020
  preventDefault = false;
761
1021
 
@@ -787,143 +1047,46 @@ function onAnchorKeydown(
787
1047
  }
788
1048
  }
789
1049
 
790
- function onAnchorKeyup(
1050
+ function onInputKeyup(
791
1051
  event: KeyboardEvent,
792
1052
  combobox: OuiComboboxElement,
793
1053
  state: OuiComboboxState,
794
1054
  ): void {
1055
+ const filter = state.filter.element ?? state.anchor;
795
1056
  const typed = event.key.length === 1 && EXPRESSION_TYPED.test(event.key);
796
1057
 
797
- if (state.type === 'select') {
798
- if (typed) {
799
- event.preventDefault();
800
- event.stopPropagation();
801
-
802
- onScrollTo(event, combobox, state);
803
- }
804
-
805
- return;
806
- }
807
-
808
- let preventDefault = true;
809
-
810
- if (typed) {
811
- state.filter += event.key;
812
- }
813
-
814
1058
  if (
815
- state.anchor instanceof HTMLInputElement &&
816
- (KEYS_REMOVE.has(event.key) || state.anchor.value.length < state.filter.length)
1059
+ !(filter instanceof HTMLInputElement) ||
1060
+ !(typed || KEYS_REMOVE.has(event.key) || filter.value.length < state.filter.value.length)
817
1061
  ) {
818
- state.filter = state.anchor.value;
819
-
820
- filterOptions(state);
821
- }
822
-
823
- switch (true) {
824
- case typed:
825
- onTyped(combobox, state);
826
- break;
827
-
828
- case KEYS_REMOVE.has(event.key):
829
- onRemove(combobox, state);
830
- break;
831
-
832
- default: {
833
- preventDefault = false;
834
-
835
- break;
836
- }
837
- }
838
-
839
- if (preventDefault) {
840
- event.preventDefault();
841
- event.stopPropagation();
842
- }
843
- }
844
-
845
- function onClick(event: PointerEvent): void {
846
- const combobox = findAncestor(event, TAGNAME);
847
- const state = states.get(combobox!);
848
-
849
- if (combobox == null || state == null) {
850
- return;
851
- }
852
-
853
- const label = findAncestor(event, 'label');
854
-
855
- if (label != null) {
856
- if (label === state.label && combobox.type === 'select') {
857
- state.anchor.focus();
858
-
859
- state.floatable.show();
860
- }
861
-
862
- return;
863
- }
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
-
875
- const anchor = findAncestor(event, SELECTOR_ANCHOR);
876
-
877
- if (anchor != null) {
878
- state.floatable.show();
879
-
880
- return;
881
- }
882
- }
883
-
884
- function onContent(event: PointerEvent, content: HTMLElement): void {
885
- const combobox = comboboxes.get(content);
886
- const state = states.get(content);
887
-
888
- if (combobox == null || state == null) {
889
1062
  return;
890
1063
  }
891
1064
 
892
- const option = findAncestor(event, SELECTOR_OPTION);
893
-
894
- if (option instanceof HTMLElement) {
895
- setNext('set', combobox, state, getOptions(state)[0], option);
896
- }
897
-
898
- state.anchor.focus();
899
- }
900
-
901
- function onEnter(combobox: OuiComboboxElement, state: OuiComboboxState): void {
902
- if (hasTextSelection(state.anchor)) {
903
- onSelection(combobox, state);
1065
+ event.preventDefault();
1066
+ event.stopPropagation();
904
1067
 
905
- return;
1068
+ if (typed) {
1069
+ state.filter.value += event.key;
1070
+ } else {
1071
+ state.filter.value = filter.value;
906
1072
  }
907
1073
 
908
- if (!state.floatable.open) {
909
- state.floatable.show();
1074
+ const [options] = getOptions(state);
1075
+ const next = filterOptions(state, options);
910
1076
 
911
- return;
1077
+ if (typed) {
1078
+ onTyped(combobox, state, options, next);
1079
+ } else {
1080
+ setNext('remove', combobox, state, options, next);
912
1081
  }
913
-
914
- setNext('set', combobox, state, getOptions(state)[0], state.active);
915
- }
916
-
917
- function onEscape(state: OuiComboboxState): void {
918
- state.floatable.hide();
919
1082
  }
920
1083
 
921
1084
  function onKeydown(event: KeyboardEvent): void {
922
- onAnchor(event, onAnchorKeydown);
1085
+ onInput(event, onInputKeydown);
923
1086
  }
924
1087
 
925
1088
  function onKeyup(event: KeyboardEvent): void {
926
- onAnchor(event, onAnchorKeyup);
1089
+ onInput(event, onInputKeyup);
927
1090
  }
928
1091
 
929
1092
  function onNavigation(
@@ -985,34 +1148,8 @@ function onNavigation(
985
1148
  setNext(state.autocomplete.none ? 'none' : 'navigation', combobox, state, all, next);
986
1149
  }
987
1150
 
988
- function onRemove(combobox: OuiComboboxElement, state: OuiComboboxState): void {
989
- const [all] = getOptions(state);
990
-
991
- const next = filterOptions(state, all);
992
-
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
1151
  function onSelection(combobox: OuiComboboxElement, state: OuiComboboxState): void {
1015
- state.filter = state.anchor instanceof HTMLInputElement ? state.anchor.value : '';
1152
+ state.filter.value = state.anchor instanceof HTMLInputElement ? state.anchor.value : '';
1016
1153
 
1017
1154
  filterOptions(state);
1018
1155
 
@@ -1025,44 +1162,42 @@ function onTab(combobox: OuiComboboxElement, state: OuiComboboxState): void {
1025
1162
  }
1026
1163
  }
1027
1164
 
1028
- function onTyped(combobox: OuiComboboxElement, state: OuiComboboxState): void {
1029
- if (
1030
- !state.autocomplete.inline &&
1031
- state.anchor instanceof HTMLInputElement &&
1032
- state.anchor.value.length > 0
1033
- ) {
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;
1172
+
1173
+ if (!state.autocomplete.inline && filter instanceof HTMLInputElement && filter.value.length > 0) {
1034
1174
  state.floatable.show();
1035
1175
  }
1036
1176
 
1037
- const options = getOptions(state)[0];
1177
+ const all = options ?? getOptions(state)[0];
1038
1178
 
1039
- const next = state.autocomplete.none ? (state.active ?? options[0]) : filterOptions(state);
1179
+ const option = state.autocomplete.none
1180
+ ? (state.active ?? all[0])
1181
+ : (next ?? filterOptions(state, options));
1040
1182
 
1041
- setNext('typed', combobox, state, options, next);
1183
+ setNext('typed', combobox, state, all, option);
1042
1184
  }
1043
1185
 
1044
1186
  function setDisabled(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1045
- state.disabled = value;
1046
-
1047
- setAria(state.anchor, 'disabled', value);
1048
- setProperty(combobox, 'disabled', value);
1049
-
1050
- if (state.anchor instanceof HTMLInputElement) {
1051
- state.anchor.readOnly = value;
1052
- }
1187
+ setProperties('disabled', 'disabled', combobox, state, value);
1053
1188
  }
1054
1189
 
1055
1190
  function setLimit(
1056
1191
  type: 'max' | 'min',
1057
1192
  combobox: OuiComboboxElement,
1058
1193
  state: OuiComboboxState,
1059
- value?: number,
1194
+ value?: unknown,
1060
1195
  ): void {
1061
1196
  const updateLimit =
1062
1197
  typeof value === 'number' && !Number.isNaN(value) ? value !== state[type] : value == null;
1063
1198
 
1064
1199
  if (updateLimit) {
1065
- state[type] = value;
1200
+ state[type] = value as number | undefined;
1066
1201
 
1067
1202
  setAttribute(combobox, type, value);
1068
1203
 
@@ -1087,26 +1222,38 @@ function setNext(
1087
1222
 
1088
1223
  next?.scrollIntoView(scrollOptions);
1089
1224
 
1225
+ // TODO: offset scroll to prevent options under headers
1226
+
1090
1227
  setValue(update, combobox, state, next);
1091
1228
  }
1092
1229
 
1093
- function setReadonly(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1094
- state.readonly = 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);
1095
1241
 
1096
- setAria(state.anchor, 'readonly', value);
1097
- setProperty(combobox, 'readonly', value);
1242
+ if (state.filter.element != null) {
1243
+ state.filter.element[property] = value;
1244
+ }
1098
1245
 
1099
1246
  if (state.anchor instanceof HTMLInputElement) {
1100
- state.anchor.readOnly = value;
1247
+ state.anchor[property] = value;
1101
1248
  }
1102
1249
  }
1103
1250
 
1104
- function setRequired(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1105
- state.required = value;
1106
-
1107
- setAria(state.anchor, 'required', value);
1108
- setProperty(combobox, 'required', value);
1251
+ function setReadonly(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1252
+ setProperties('readonly', 'readOnly', combobox, state, value);
1253
+ }
1109
1254
 
1255
+ function setRequired(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1256
+ setProperties('required', 'required', combobox, state, value);
1110
1257
  updateValidity(state);
1111
1258
  }
1112
1259
 
@@ -1214,13 +1361,13 @@ function setValues(combobox: OuiComboboxElement, state: OuiComboboxState, value:
1214
1361
  for (const option of state.selected) {
1215
1362
  setAria(option, 'selected', false);
1216
1363
 
1217
- state.anchor.querySelector(`[${ATTRIBUTE_SELECTION_ITEM}][option="${option.id}"]`)?.remove();
1364
+ state.selection?.querySelector(`[option="${option.id}"]`)?.remove();
1218
1365
  }
1219
1366
 
1220
1367
  for (const option of selected) {
1221
1368
  setAria(option, 'selected', true);
1222
1369
 
1223
- state.anchor.querySelector(`[${ATTRIBUTE_SELECTION}]`)?.append(getSelectionItem(option));
1370
+ state.selection?.append(getSelectionItem(option));
1224
1371
  }
1225
1372
 
1226
1373
  state.selected = selected.slice();
@@ -1243,7 +1390,7 @@ function updateAutoComplete(
1243
1390
 
1244
1391
  if (
1245
1392
  update === 'typed' &&
1246
- !content.toLocaleLowerCase().startsWith(state.filter.toLocaleLowerCase())
1393
+ !content.toLocaleLowerCase().startsWith(state.filter.value.toLocaleLowerCase())
1247
1394
  ) {
1248
1395
  return;
1249
1396
  }
@@ -1253,7 +1400,7 @@ function updateAutoComplete(
1253
1400
  if (state.anchor instanceof HTMLInputElement) {
1254
1401
  state.anchor.value = content;
1255
1402
 
1256
- state.anchor.setSelectionRange(update === 'typed' ? state.filter.length : length, length);
1403
+ state.anchor.setSelectionRange(update === 'typed' ? state.filter.value.length : length, length);
1257
1404
  } else {
1258
1405
  state.anchor.textContent = content;
1259
1406
  }
@@ -1294,9 +1441,9 @@ function updateSelect(
1294
1441
  }
1295
1442
 
1296
1443
  if (added === true) {
1297
- state.anchor.querySelector(`[${ATTRIBUTE_SELECTION}]`)?.append(getSelectionItem(next));
1444
+ state.selection?.append(getSelectionItem(next));
1298
1445
  } else if (added === false) {
1299
- state.anchor.querySelector(`[${ATTRIBUTE_SELECTION_ITEM}][option="${next.id}"]`)?.remove();
1446
+ state.selection?.querySelector(`[option="${next.id}"]`)?.remove();
1300
1447
  }
1301
1448
  }
1302
1449
 
@@ -1406,6 +1553,14 @@ const ATTRIBUTE_CONTENT = `${TAGNAME}-content`;
1406
1553
 
1407
1554
  const ATTRIBUTE_EMPTY = `${TAGNAME}-empty`;
1408
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
+
1409
1564
  const ATTRIBUTE_LIST = `${TAGNAME}-list`;
1410
1565
 
1411
1566
  const ATTRIBUTE_OPTION = `${TAGNAME}-option`;
@@ -1428,8 +1583,6 @@ const INVALID_OVERFLOW = 'Please select fewer options (maximum is {max})';
1428
1583
 
1429
1584
  const INVALID_UNDERFLOW = 'Please select more options (minimum is {min})';
1430
1585
 
1431
- const KEYS_ABSOLUTE = new Set(['End', 'Home']);
1432
-
1433
1586
  const KEYS_NAVIGATION = new Set(['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp']);
1434
1587
 
1435
1588
  const KEYS_REMOVE = new Set(['Backspace', 'Delete']);
@@ -1444,6 +1597,8 @@ const PROPERTY_STYLE = '--oui-combobox-width';
1444
1597
 
1445
1598
  const SELECTOR_ANCHOR = `[${ATTRIBUTE_ANCHOR}]`;
1446
1599
 
1600
+ const SELECTOR_FILTER = `[${ATTRIBUTE_FILTER_INPUT}]`;
1601
+
1447
1602
  const SELECTOR_OPTION = `[${ATTRIBUTE_OPTION}]`;
1448
1603
 
1449
1604
  const TYPES = new Set<OuiComboboxType>(['autocomplete', 'select']);