@oscarpalmer/oui 0.29.1 → 0.30.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.
Files changed (45) hide show
  1. package/dist/css/combobox.css +26 -0
  2. package/dist/js/combobox.d.mts +27 -0
  3. package/dist/js/combobox.mjs +474 -0
  4. package/dist/js/dialog.d.mts +4 -3
  5. package/dist/js/dialog.mjs +1 -1
  6. package/dist/js/{draggable-fIYXd8eO.d.mts → draggable-a6w0J1OD.d.mts} +1 -2
  7. package/dist/js/floatable/floatable.embedded.d.mts +1 -1
  8. package/dist/js/floatable/floatable.embedded.mjs +2 -2
  9. package/dist/js/floatable/floatable.standalone.d.mts +1 -1
  10. package/dist/js/{floatable.embedded-DUR8bQBK.d.mts → floatable.embedded-Bct4U5Dm.d.mts} +0 -1
  11. package/dist/js/focus-trap/focus-trap.embedded.mjs +13 -8
  12. package/dist/js/index.d.mts +9 -4
  13. package/dist/js/index.mjs +2 -1
  14. package/dist/js/internal/draggable.d.mts +1 -1
  15. package/dist/js/internal/draggable.mjs +24 -15
  16. package/dist/js/internal/navigable.d.mts +0 -1
  17. package/dist/js/internal/navigable.mjs +4 -3
  18. package/dist/js/menu.mjs +1 -1
  19. package/dist/js/movable/movable.embedded.d.mts +1 -1
  20. package/dist/js/movable/movable.embedded.mjs +3 -4
  21. package/dist/js/movable/movable.standalone.d.mts +1 -1
  22. package/dist/js/popover.d.mts +1 -2
  23. package/dist/js/popover.mjs +1 -1
  24. package/dist/js/resizable/resizable.embedded.d.mts +1 -1
  25. package/dist/js/resizable/resizable.embedded.mjs +47 -19
  26. package/dist/js/resizable/resizable.standalone.d.mts +1 -1
  27. package/dist/js/{resizable.embedded-CXrUVPzR.d.mts → resizable.embedded-3qoR0Bjm.d.mts} +0 -1
  28. package/dist/js/sortable/sortable.embedded.d.mts +1 -1
  29. package/dist/js/sortable/sortable.embedded.mjs +11 -15
  30. package/dist/js/sortable/sortable.standalone.d.mts +1 -1
  31. package/dist/js/splitter.d.mts +2 -2
  32. package/dist/js/splitter.mjs +11 -11
  33. package/dist/js/tooltip.d.mts +1 -2
  34. package/dist/js/tooltip.mjs +3 -3
  35. package/package.json +6 -2
  36. package/src/css/combobox.scss +26 -0
  37. package/src/js/combobox.ts +917 -0
  38. package/src/js/focus-trap/focus-trap.embedded.ts +22 -10
  39. package/src/js/index.ts +1 -0
  40. package/src/js/internal/draggable.ts +37 -29
  41. package/src/js/internal/navigable.ts +5 -1
  42. package/src/js/movable/movable.embedded.ts +1 -3
  43. package/src/js/resizable/resizable.embedded.ts +68 -18
  44. package/src/js/sortable/sortable.embedded.ts +7 -15
  45. package/src/js/splitter.ts +37 -23
@@ -0,0 +1,917 @@
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';
5
+ import {createElement} from '@oscarpalmer/toretto/create';
6
+ import {dispatch, on} from '@oscarpalmer/toretto/event';
7
+ import {findAncestor} from '@oscarpalmer/toretto/find';
8
+ import type {RemovableEventListener} from '@oscarpalmer/toretto/models';
9
+ import {setProperty} from '@oscarpalmer/toretto/property';
10
+ import {setStyle} from '@oscarpalmer/toretto/style';
11
+ import {createEmbeddedFloatable, type OuiFloatable} from './floatable/floatable.embedded';
12
+
13
+ // #region Types
14
+
15
+ declare global {
16
+ interface HTMLElementTagNameMap {
17
+ [TAGNAME]: OuiComboboxElement;
18
+ }
19
+ }
20
+
21
+ export class OuiComboboxElement extends HTMLElement {
22
+ static formAssociated = true;
23
+
24
+ static observedAttributes = ['disabled', 'required', 'value'];
25
+
26
+ readonly #internals: ElementInternals;
27
+
28
+ readonly #state: OuiComboboxState;
29
+
30
+ get disabled(): boolean {
31
+ return this.#state?.disabled ?? false;
32
+ }
33
+
34
+ set disabled(value: boolean) {
35
+ if (typeof value === 'boolean' && value !== this.#state.disabled) {
36
+ setDisabled(this, this.#state, value);
37
+ }
38
+ }
39
+
40
+ get multiple(): boolean {
41
+ return this.hasAttribute('multiple');
42
+ }
43
+
44
+ get name(): string {
45
+ return this.getAttribute('name') ?? '';
46
+ }
47
+
48
+ set name(value: string) {
49
+ if (typeof value === 'string') {
50
+ this.setAttribute('name', value);
51
+ }
52
+ }
53
+
54
+ get required(): boolean {
55
+ return this.hasAttribute('required');
56
+ }
57
+
58
+ set required(value: boolean) {
59
+ if (typeof value === 'boolean' && value !== this.#state.required) {
60
+ setRequired(this, this.#state, value);
61
+ }
62
+ }
63
+
64
+ get type(): OuiComboboxType {
65
+ return this.#state.type;
66
+ }
67
+
68
+ get value(): string {
69
+ return this.#state.value;
70
+ }
71
+
72
+ set value(value: unknown) {
73
+ const [option, all] = getOption(this.#state, value);
74
+
75
+ if (option != null) {
76
+ setNext(this, this.#state, all, 'set', option);
77
+ }
78
+ }
79
+
80
+ constructor() {
81
+ super();
82
+
83
+ this.#internals = this.attachInternals();
84
+ this.#state = getState(this, this.#internals);
85
+
86
+ comboboxes.set(this.#state.content, this);
87
+
88
+ states.set(this, this.#state);
89
+ states.set(this.#state.content, this.#state);
90
+ }
91
+
92
+ attributeChangedCallback(name: string, _: string | null, value: string | null): void {
93
+ switch (name) {
94
+ case 'disabled':
95
+ this.disabled = this.hasAttribute('disabled');
96
+ break;
97
+
98
+ case 'required':
99
+ this.required = this.hasAttribute('required');
100
+ break;
101
+
102
+ case 'value':
103
+ this.value = value;
104
+ break;
105
+ }
106
+ }
107
+ }
108
+
109
+ type OuiComboboxAutoComplete = Record<OuiComboboxAutoCompleteType, boolean> & {
110
+ value: OuiComboboxAutoCompleteType;
111
+ };
112
+
113
+ type OuiComboboxAutoCompleteType = 'both' | 'inline' | 'list' | 'none';
114
+
115
+ type OuiComboboxState = {
116
+ active?: HTMLElement;
117
+ anchor: HTMLElement;
118
+ autocomplete: OuiComboboxAutoComplete;
119
+ content: HTMLElement;
120
+ disabled: boolean;
121
+ filter: string;
122
+ floatable: OuiFloatable;
123
+ id: string;
124
+ internals: ElementInternals;
125
+ label: HTMLLabelElement;
126
+ listeners: RemovableEventListener[];
127
+ multiple: boolean;
128
+ required: boolean;
129
+ select: HTMLSelectElement;
130
+ selected: HTMLElement[];
131
+ type: OuiComboboxType;
132
+ value: string;
133
+ };
134
+
135
+ type OuiComboboxType = 'autocomplete' | 'select';
136
+
137
+ // #endregion
138
+
139
+ // #region Functions
140
+
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(
173
+ type: OuiComboboxType,
174
+ id: string,
175
+ element: OuiComboboxElement,
176
+ label: HTMLLabelElement,
177
+ select: HTMLSelectElement,
178
+ 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',
194
+ });
195
+
196
+ return anchor;
197
+ }
198
+
199
+ function getAutoComplete(element: OuiComboboxElement, select: boolean): OuiComboboxAutoComplete {
200
+ let value: OuiComboboxAutoCompleteType;
201
+
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
+ };
217
+ }
218
+
219
+ function getBox(
220
+ id: string,
221
+ element: OuiComboboxElement,
222
+ label: HTMLLabelElement,
223
+ select: HTMLSelectElement,
224
+ ): HTMLElement {
225
+ return createElement('div', {
226
+ id,
227
+ innerHTML: select.selectedOptions[0]?.textContent ?? '',
228
+ tabIndex: 0,
229
+ });
230
+ }
231
+
232
+ function getContent(
233
+ type: OuiComboboxType,
234
+ id: string,
235
+ element: OuiComboboxElement,
236
+ anchor: HTMLElement,
237
+ label: HTMLLabelElement,
238
+ select: HTMLSelectElement,
239
+ autocomplete: OuiComboboxAutoComplete,
240
+ ): [HTMLElement, HTMLElement[]] {
241
+ const content = createElement(
242
+ 'div',
243
+ {
244
+ id: `${id}_content`,
245
+ },
246
+ {
247
+ [ATTRIBUTE_CONTENT]: '',
248
+ popover: '',
249
+ role: 'listbox',
250
+ tabindex: -1,
251
+ 'aria-labelledby': label.id,
252
+ },
253
+ );
254
+
255
+ const list = createElement(
256
+ 'ul',
257
+ {},
258
+ {
259
+ [ATTRIBUTE_LIST]: '',
260
+ },
261
+ );
262
+
263
+ const options = [...select.options];
264
+
265
+ const selected: HTMLElement[] = [];
266
+
267
+ for (let index = 0; index < options.length; index += 1) {
268
+ const option = options[index];
269
+
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
+ );
284
+
285
+ if (option.selected) {
286
+ selected.push(item);
287
+ }
288
+
289
+ list.append(item);
290
+ }
291
+
292
+ content.append(list);
293
+
294
+ return [content, selected];
295
+ }
296
+
297
+ function getId(): string {
298
+ index += 1;
299
+
300
+ return `oui_combobox_${index}`;
301
+ }
302
+
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
+ function getOption(
324
+ state: OuiComboboxState,
325
+ value: unknown,
326
+ ): [HTMLElement | undefined, HTMLElement[]] {
327
+ const [all] = getOptions(state);
328
+ const valueAsString = getString(value);
329
+
330
+ filterOptions(state, all, true);
331
+
332
+ let match: HTMLElement | undefined;
333
+
334
+ for (const option of all) {
335
+ if (option.getAttribute('value')?.toLocaleLowerCase() === valueAsString) {
336
+ match = option;
337
+ break;
338
+ }
339
+ }
340
+
341
+ return [match, all];
342
+ }
343
+
344
+ function getOptions(state: OuiComboboxState): HTMLElement[][] {
345
+ const all = [...state.content.querySelectorAll(`[${ATTRIBUTE_OPTION}]`)].filter(
346
+ option => option instanceof HTMLElement,
347
+ );
348
+
349
+ const visible = all.filter(option => !option.hidden);
350
+
351
+ return [all, visible];
352
+ }
353
+
354
+ function getSelected(
355
+ update: 'navigation' | 'none' | 'set' | 'typed',
356
+ state: OuiComboboxState,
357
+ element: HTMLElement,
358
+ next?: HTMLElement,
359
+ ): boolean {
360
+ if (state.autocomplete.none) {
361
+ return false;
362
+ }
363
+
364
+ if (update === 'navigation') {
365
+ if (state.autocomplete.both) {
366
+ return element === next;
367
+ }
368
+
369
+ return getAria(element, 'selected') === 'true';
370
+ }
371
+
372
+ if (update === 'none') {
373
+ return getAria(element, 'selected') === 'true';
374
+ }
375
+
376
+ if (update === 'set') {
377
+ return element === next;
378
+ }
379
+
380
+ return state.autocomplete.both && element === next;
381
+ }
382
+
383
+ function getState(element: OuiComboboxElement, internals: ElementInternals): OuiComboboxState {
384
+ const select = element.querySelector('select');
385
+
386
+ if (select == null) {
387
+ throw new Error(MESSAGE_SELECT);
388
+ }
389
+
390
+ const label = element.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement;
391
+
392
+ if (label == null) {
393
+ throw new Error(MESSAGE_LABEL);
394
+ }
395
+
396
+ select.hidden = true;
397
+
398
+ element.id = select.id;
399
+ element.name = select.name === '' ? select.id : select.name;
400
+
401
+ const disabled = element.disabled || select.disabled;
402
+ const multiple = element.multiple || select.multiple;
403
+ const required = element.required || select.required;
404
+
405
+ const id = getId();
406
+ const type = multiple ? 'select' : getType(element);
407
+
408
+ const autocomplete = getAutoComplete(element, type === 'select');
409
+
410
+ label.htmlFor = id;
411
+ label.id = `${id}_label`;
412
+
413
+ const anchor = getAnchor(type, id, element, label, select, autocomplete);
414
+ const [content, selected] = getContent(type, id, element, anchor, label, select, autocomplete);
415
+
416
+ const floatable = createEmbeddedFloatable(anchor, content, {
417
+ position: 'below-start',
418
+ reusable: false,
419
+ });
420
+
421
+ floatable.update();
422
+
423
+ select.insertAdjacentElement('afterend', anchor);
424
+ anchor.insertAdjacentElement('afterend', content);
425
+
426
+ select.remove();
427
+
428
+ const state: OuiComboboxState = {
429
+ anchor,
430
+ autocomplete,
431
+ content,
432
+ disabled,
433
+ floatable,
434
+ id,
435
+ internals,
436
+ label,
437
+ multiple,
438
+ required,
439
+ select,
440
+ selected,
441
+ type,
442
+ filter: '',
443
+ listeners: [],
444
+ value: '',
445
+ };
446
+
447
+ state.listeners.push(
448
+ on(content, 'click', event => {
449
+ onContent(event, content);
450
+ }),
451
+ on(content, 'toggle', () => {
452
+ setAria(anchor, {
453
+ activedescendant: floatable.open ? state.active?.id : undefined,
454
+ expanded: floatable.open,
455
+ });
456
+
457
+ if (floatable.open) {
458
+ setStyle(content, '--oui-combobox-width', `${anchor.getBoundingClientRect().width}px`);
459
+ }
460
+ }),
461
+ );
462
+
463
+ setDisabled(element, state, disabled);
464
+
465
+ return state;
466
+ }
467
+
468
+ function getType(element: OuiComboboxElement): OuiComboboxType {
469
+ const type = getAttribute(element, ATTRIBUTE_TYPE) as OuiComboboxType;
470
+
471
+ return TYPES.has(type) ? type : 'select';
472
+ }
473
+
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
+
482
+ const combobox = findAncestor(event, TAGNAME);
483
+ const state = states.get(combobox!);
484
+
485
+ if (combobox == null || state == null) {
486
+ return;
487
+ }
488
+
489
+ const anchor = findAncestor(event, SELECTOR_ANCHOR);
490
+
491
+ if (anchor != null) {
492
+ handler(event, combobox, state);
493
+ }
494
+ }
495
+
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;
507
+
508
+ case event.key === 'Escape':
509
+ onEscape(combobox, state);
510
+ break;
511
+
512
+ case state.type === 'select' && KEYS_ABSOLUTE.has(event.key):
513
+ onNavigation(event, combobox, state);
514
+ break;
515
+
516
+ case KEYS_NAVIGATION.has(event.key):
517
+ onNavigation(event, combobox, state);
518
+ break;
519
+
520
+ default: {
521
+ preventDefault = false;
522
+
523
+ if (event.key === 'Tab') {
524
+ onTab(combobox, state);
525
+ }
526
+
527
+ break;
528
+ }
529
+ }
530
+
531
+ if (preventDefault) {
532
+ event.preventDefault();
533
+ event.stopPropagation();
534
+ }
535
+ }
536
+
537
+ function onAnchorKeyup(
538
+ event: KeyboardEvent,
539
+ combobox: OuiComboboxElement,
540
+ state: OuiComboboxState,
541
+ ): void {
542
+ if (state.type === 'select') {
543
+ return;
544
+ }
545
+
546
+ let preventDefault = true;
547
+
548
+ const typed = event.key.length === 1 && EXPRESSION_TYPED.test(event.key);
549
+
550
+ if (typed) {
551
+ state.filter += event.key;
552
+ }
553
+
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;
559
+
560
+ filterOptions(state);
561
+ }
562
+
563
+ switch (true) {
564
+ case typed:
565
+ onTyped(combobox, state);
566
+ break;
567
+
568
+ case KEYS_REMOVE.has(event.key):
569
+ onRemove(combobox, state);
570
+ break;
571
+
572
+ default: {
573
+ preventDefault = false;
574
+
575
+ break;
576
+ }
577
+ }
578
+
579
+ if (preventDefault) {
580
+ event.preventDefault();
581
+ event.stopPropagation();
582
+ }
583
+ }
584
+
585
+ function onClick(event: PointerEvent): void {
586
+ const combobox = findAncestor(event, TAGNAME);
587
+ const state = states.get(combobox!);
588
+
589
+ if (combobox == null || state == null) {
590
+ return;
591
+ }
592
+
593
+ const label = findAncestor(event, 'label');
594
+
595
+ if (label != null) {
596
+ if (label === state.label && combobox.type === 'select') {
597
+ state.anchor.focus();
598
+ }
599
+
600
+ return;
601
+ }
602
+
603
+ const anchor = findAncestor(event, SELECTOR_ANCHOR);
604
+
605
+ if (anchor != null) {
606
+ state.floatable.show();
607
+
608
+ return;
609
+ }
610
+ }
611
+
612
+ function onContent(event: PointerEvent, content: HTMLElement): void {
613
+ const combobox = comboboxes.get(content);
614
+ const state = states.get(content);
615
+
616
+ if (combobox == null || state == null) {
617
+ return;
618
+ }
619
+
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
+ }
628
+
629
+ 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();
634
+ }
635
+ }
636
+
637
+ function onEscape(combobox: OuiComboboxElement, state: OuiComboboxState): void {
638
+ if (!state.floatable.open) {
639
+ return;
640
+ }
641
+
642
+ if (state.type === 'select') {
643
+ state.floatable.hide();
644
+ } else {
645
+ setNext(combobox, state, getOptions(state)[0], 'set', state.active);
646
+ }
647
+ }
648
+
649
+ function onKeydown(event: KeyboardEvent): void {
650
+ onAnchor(event, onAnchorKeydown);
651
+ }
652
+
653
+ function onKeyup(event: KeyboardEvent): void {
654
+ onAnchor(event, onAnchorKeyup);
655
+ }
656
+
657
+ function onNavigation(
658
+ event: KeyboardEvent,
659
+ combobox: OuiComboboxElement,
660
+ state: OuiComboboxState,
661
+ ): void {
662
+ let skip = false;
663
+
664
+ if (!state.floatable.open) {
665
+ skip = true;
666
+
667
+ state.floatable.show();
668
+ }
669
+
670
+ const [all, visible] = getOptions(state);
671
+
672
+ if (visible.length === 0) {
673
+ return;
674
+ }
675
+
676
+ let next: HTMLElement | undefined;
677
+
678
+ if (skip ?? state.active == null) {
679
+ next = state.active ?? visible[0];
680
+ } else {
681
+ const current = visible.findIndex(option => option.id === state.active?.id);
682
+
683
+ let index: number | undefined;
684
+
685
+ switch (event.key) {
686
+ case 'ArrowDown':
687
+ case 'ArrowUp':
688
+ index = clamp(current + (event.key === 'ArrowDown' ? 1 : -1), 0, visible.length - 1, true);
689
+ break;
690
+
691
+ case 'End':
692
+ index = visible.length - 1;
693
+ break;
694
+
695
+ case 'Home':
696
+ index = 0;
697
+ break;
698
+
699
+ case 'PageDown':
700
+ case 'PageUp':
701
+ index = clamp(current + (event.key === 'PageDown' ? 10 : -10), 0, visible.length - 1);
702
+ break;
703
+
704
+ default:
705
+ break;
706
+ }
707
+
708
+ if (index != null) {
709
+ next = visible[index];
710
+ }
711
+ }
712
+
713
+ setNext(combobox, state, all, state.autocomplete.none ? 'none' : 'navigation', next);
714
+ }
715
+
716
+ function onRemove(combobox: OuiComboboxElement, state: OuiComboboxState): void {
717
+ const [all] = getOptions(state);
718
+
719
+ const next = filterOptions(state, all);
720
+
721
+ setNext(combobox, state, all, 'none', next);
722
+ }
723
+
724
+ function onTab(combobox: OuiComboboxElement, state: OuiComboboxState): void {
725
+ if (combobox !== document.activeElement || !combobox.contains(document.activeElement)) {
726
+ state.floatable.hide();
727
+ }
728
+ }
729
+
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
+ }
740
+
741
+ if (!state.autocomplete.none) {
742
+ const next = filterOptions(state);
743
+
744
+ state.active =
745
+ next != null &&
746
+ next.textContent?.toLocaleLowerCase().indexOf(state.filter.toLocaleLowerCase()) === 0
747
+ ? next
748
+ : undefined;
749
+
750
+ if (next != null) {
751
+ open();
752
+ }
753
+
754
+ setNext(combobox, state, getOptions(state)[0], 'typed', next);
755
+ } else {
756
+ open();
757
+ }
758
+ }
759
+
760
+ function setDisabled(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
761
+ state.disabled = value;
762
+
763
+ setAria(state.anchor, 'disabled', value);
764
+ setProperty(combobox, 'disabled', value);
765
+
766
+ if (state.anchor instanceof HTMLInputElement) {
767
+ state.anchor.readOnly = value;
768
+ }
769
+ }
770
+
771
+ function setNext(
772
+ combobox: OuiComboboxElement,
773
+ state: OuiComboboxState,
774
+ all: HTMLElement[],
775
+ update: 'navigation' | 'none' | 'set' | 'typed',
776
+ next?: HTMLElement,
777
+ ): void {
778
+ for (const option of all) {
779
+ setAttribute(option, ATTRIBUTE_OPTION_CURRENT, option === next ? '' : undefined);
780
+ }
781
+
782
+ state.active = next;
783
+
784
+ setAria(state.anchor, 'activedescendant', next?.id);
785
+
786
+ next?.scrollIntoView(scrollOptions);
787
+
788
+ setValue(combobox, state, update, next);
789
+ }
790
+
791
+ function setRequired(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
792
+ state.required = value;
793
+
794
+ setAria(state.anchor, 'required', value);
795
+ setProperty(combobox, 'required', value);
796
+ }
797
+
798
+ function setValue(
799
+ combobox: OuiComboboxElement,
800
+ state: OuiComboboxState,
801
+ update: 'navigation' | 'none' | 'set' | 'typed',
802
+ next?: HTMLElement,
803
+ ): void {
804
+ if (state.disabled) {
805
+ return;
806
+ }
807
+
808
+ const [all] = getOptions(state);
809
+
810
+ for (const option of all) {
811
+ setAria(option, 'selected', getSelected(update, state, option, next));
812
+ }
813
+
814
+ state.select.value = next?.getAttribute('value') ?? '';
815
+
816
+ if (state.value !== state.select.value) {
817
+ state.value = state.select.value;
818
+
819
+ state.internals.setFormValue(state.value);
820
+
821
+ dispatch(combobox, 'change');
822
+ dispatch(state.select, 'change');
823
+ }
824
+
825
+ if (next == null || update === 'none') {
826
+ return;
827
+ }
828
+
829
+ if (update === 'set' || state.autocomplete.both || state.autocomplete.inline) {
830
+ const textContent = next.textContent ?? '';
831
+
832
+ if (state.anchor instanceof HTMLInputElement) {
833
+ state.anchor.value = textContent;
834
+ } else {
835
+ state.anchor.innerHTML = textContent;
836
+ }
837
+
838
+ if (state.anchor instanceof HTMLInputElement) {
839
+ state.anchor.setSelectionRange(
840
+ update === 'typed' ? state.filter.length : textContent.length,
841
+ textContent.length,
842
+ );
843
+ }
844
+ }
845
+
846
+ if (update === 'set') {
847
+ state.floatable.hide();
848
+ }
849
+ }
850
+
851
+ // #endregion
852
+
853
+ // #region Variables
854
+
855
+ const TAGNAME = 'oui-combobox';
856
+
857
+ const ATTRIBUTE_ANCHOR = `${TAGNAME}-anchor`;
858
+
859
+ const ATTRIBUTE_CONTENT = `${TAGNAME}-content`;
860
+
861
+ const ATTRIBUTE_LIST = `${TAGNAME}-list`;
862
+
863
+ const ATTRIBUTE_OPTION = `${TAGNAME}-option`;
864
+
865
+ const ATTRIBUTE_OPTION_CURRENT = `${TAGNAME}-option-current`;
866
+
867
+ const ATTRIBUTE_TYPE = 'type';
868
+
869
+ const AUTOCOMPLETES = new Set<keyof OuiComboboxAutoComplete>(['both', 'inline', 'list', 'none']);
870
+
871
+ const EXPRESSION_TYPED = /\p{L}|\s/u;
872
+
873
+ const KEYS_ABSOLUTE = new Set(['End', 'Home']);
874
+
875
+ const KEYS_NAVIGATION = new Set(['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp']);
876
+
877
+ const KEYS_REMOVE = new Set(['Backspace', 'Delete']);
878
+
879
+ const MESSAGE_LABEL = `<${TAGNAME}> must contain a <label> element for the <select> element`;
880
+
881
+ const MESSAGE_SELECT = `<${TAGNAME}> must contain a <select> element`;
882
+
883
+ const SELECTOR_ANCHOR = `[${ATTRIBUTE_ANCHOR}]`;
884
+
885
+ const SELECTOR_OPTION = `[${ATTRIBUTE_OPTION}]`;
886
+
887
+ const TYPES = new Set<OuiComboboxType>(['autocomplete', 'select']);
888
+
889
+ const comboboxes = new WeakMap<HTMLElement, OuiComboboxElement>();
890
+
891
+ const scrollOptions: ScrollIntoViewOptions = {
892
+ behavior: 'smooth',
893
+ block: 'nearest',
894
+ inline: 'nearest',
895
+ };
896
+
897
+ const states = new WeakMap<HTMLElement, OuiComboboxState>();
898
+
899
+ let index = 0;
900
+
901
+ // #endregion
902
+
903
+ // #region Initialization
904
+
905
+ on(document, 'click', onClick);
906
+
907
+ on(document, 'keydown', onKeydown, {
908
+ passive: false,
909
+ });
910
+
911
+ on(document, 'keyup', onKeyup, {
912
+ passive: false,
913
+ });
914
+
915
+ customElements.define(TAGNAME, OuiComboboxElement);
916
+
917
+ // #endregion