@oscarpalmer/oui 0.31.0 → 0.33.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,13 +1,13 @@
1
+ import type {GenericCallback} from '@oscarpalmer/atoms/models';
1
2
  import {clamp} from '@oscarpalmer/atoms/number';
2
- import {getString, join} from '@oscarpalmer/atoms/string';
3
- import {getAria, setAria, setRole} from '@oscarpalmer/toretto/aria';
3
+ import {getString, parse} from '@oscarpalmer/atoms/string';
4
+ import {getAria, setAria} from '@oscarpalmer/toretto/aria';
4
5
  import {getAttribute, setAttribute} from '@oscarpalmer/toretto/attribute';
5
6
  import {createElement} from '@oscarpalmer/toretto/create';
7
+ import {getData, setData} from '@oscarpalmer/toretto/data';
6
8
  import {dispatch, on} from '@oscarpalmer/toretto/event';
7
9
  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';
10
+ import type {AnyAriaBooleanAttribute, RemovableEventListener} from '@oscarpalmer/toretto/models';
11
11
  import {createEmbeddedFloatable, type OuiFloatable} from './floatable/floatable.embedded';
12
12
 
13
13
  // #region Types
@@ -18,57 +18,188 @@ declare global {
18
18
  }
19
19
  }
20
20
 
21
+ type ApplySelectionParameters = {
22
+ added: boolean;
23
+ emit?: boolean;
24
+ option?: HTMLElement;
25
+ selected?: HTMLElement[];
26
+ state: OuiComboboxState;
27
+ values?: string[];
28
+ };
29
+
30
+ type CreateAnchor = {
31
+ container?: HTMLElement;
32
+ element: HTMLElement;
33
+ selection?: OuiComboboxSelection;
34
+ };
35
+
36
+ type CreatAnchorParameters = {
37
+ autocomplete: OuiComboboxAutoComplete;
38
+ id: string;
39
+ label: HTMLLabelElement;
40
+ multiple: boolean;
41
+ select: HTMLSelectElement;
42
+ type: OuiComboboxType;
43
+ };
44
+
45
+ type CreateContent = {
46
+ element: HTMLElement;
47
+ filter?: CreateFilter;
48
+ list: CreateList;
49
+ message: HTMLElement;
50
+ };
51
+
52
+ type CreateContentParameters = {
53
+ autocomplete: OuiComboboxAutoComplete;
54
+ id: string;
55
+ label: HTMLLabelElement;
56
+ multiple: boolean;
57
+ select: HTMLSelectElement;
58
+ type: OuiComboboxType;
59
+ };
60
+
61
+ type CreateFilter = {
62
+ container: HTMLElement;
63
+ element: HTMLInputElement;
64
+ };
65
+
66
+ type CreateFilterParameters = {
67
+ autocomplete: OuiComboboxAutoCompleteType;
68
+ id: string;
69
+ label: HTMLLabelElement;
70
+ type: OuiComboboxType;
71
+ };
72
+
73
+ type CreateGroupParameters = {
74
+ autocomplete: OuiComboboxAutoComplete;
75
+ group: HTMLOptGroupElement;
76
+ groupOptions: HTMLOptionElement[];
77
+ selected: HTMLElement[];
78
+ };
79
+
80
+ type CreateInputParameters = {
81
+ autocomplete: OuiComboboxAutoCompleteType;
82
+ id: string;
83
+ label: HTMLLabelElement;
84
+ type: OuiComboboxType;
85
+ value: string;
86
+ };
87
+
88
+ type CreateList = {
89
+ element: HTMLElement;
90
+ groups?: OuiComboboxGroup[];
91
+ options: HTMLElement[];
92
+ selected: HTMLElement[];
93
+ };
94
+
95
+ type CreateListParameters = {
96
+ autocomplete: OuiComboboxAutoComplete;
97
+ id: string;
98
+ label: HTMLLabelElement;
99
+ multiple: boolean;
100
+ select: HTMLSelectElement;
101
+ };
102
+
103
+ type CreateOptionParameters = {
104
+ autocomplete: OuiComboboxAutoComplete;
105
+ group?: string;
106
+ index: number;
107
+ option: HTMLOptionElement;
108
+ };
109
+
110
+ type CreateOptionsParameters = {
111
+ autocomplete: OuiComboboxAutoComplete;
112
+ group?: string;
113
+ list: HTMLElement;
114
+ options: HTMLOptionElement[];
115
+ selected: HTMLElement[];
116
+ };
117
+
118
+ type CreateSelectionItemsParameters = {
119
+ container: HTMLElement;
120
+ items: OuiComboboxSelectionItem[];
121
+ options: HTMLElement[];
122
+ state?: OuiComboboxState;
123
+ };
124
+
125
+ type OuiComboboxAnchor = {
126
+ container?: HTMLElement;
127
+ element: HTMLElement;
128
+ };
129
+
130
+ type OuiComboboxAutoComplete = Record<OuiComboboxAutoCompleteType, boolean> & {
131
+ value: OuiComboboxAutoCompleteType;
132
+ };
133
+
134
+ type OuiComboboxAutoCompleteType = 'both' | 'inline' | 'list' | 'none';
135
+
21
136
  export class OuiComboboxElement extends HTMLElement {
22
137
  static formAssociated = true;
23
138
 
24
- static observedAttributes = ['disabled', 'max', 'min', 'readonly', 'required', 'value'];
139
+ static observedAttributes = ['max', 'min', 'required', 'value'];
25
140
 
26
141
  readonly #internals: ElementInternals;
27
142
 
28
- readonly #state: OuiComboboxState;
143
+ #restored?: string[];
144
+
145
+ #state!: OuiComboboxState;
29
146
 
30
147
  /**
31
148
  * Is the combobox disabled?
32
149
  */
33
150
  get disabled(): boolean {
34
- return this.hasAttribute('disabled');
151
+ return this.#state?.disabled ?? false;
35
152
  }
36
153
 
37
154
  /**
38
155
  * Should the combobox be disabled?
39
156
  */
40
157
  set disabled(value: boolean) {
41
- if (typeof value === 'boolean' && value !== this.#state.disabled) {
42
- setDisabled(this, this.#state, value);
158
+ if (typeof value === 'boolean' && value !== this.disabled) {
159
+ setDisabled(this.#state, value);
43
160
  }
44
161
  }
45
162
 
163
+ /**
164
+ * Current filter value
165
+ */
166
+ get filter(): string {
167
+ return this.#state.filter.element.value;
168
+ }
169
+
170
+ /**
171
+ * Set the filter value
172
+ */
173
+ set filter(value: string) {
174
+ setFilter(this.#state, value);
175
+ }
176
+
46
177
  /**
47
178
  * Maximum number of selected options
48
179
  */
49
180
  get max(): number | undefined {
50
- return this.#state.max;
181
+ return this.#state?.max;
51
182
  }
52
183
 
53
184
  /**
54
185
  * Set the maximum number of selected options
55
186
  */
56
187
  set max(value: number) {
57
- setLimit('max', this, this.#state, value);
188
+ setLimit('max', this.#state, value);
58
189
  }
59
190
 
60
191
  /**
61
192
  * Minimum number of selected options
62
193
  */
63
194
  get min(): number | undefined {
64
- return this.#state.min;
195
+ return this.#state?.min;
65
196
  }
66
197
 
67
198
  /**
68
199
  * Set the minimum number of selected options
69
200
  */
70
201
  set min(value: number) {
71
- setLimit('min', this, this.#state, value);
202
+ setLimit('min', this.#state, value);
72
203
  }
73
204
 
74
205
  /**
@@ -89,15 +220,15 @@ export class OuiComboboxElement extends HTMLElement {
89
220
  * Is the combobox readonly?
90
221
  */
91
222
  get readonly(): boolean {
92
- return this.hasAttribute('readonly');
223
+ return this.#state?.readonly ?? false;
93
224
  }
94
225
 
95
226
  /**
96
227
  * Should the combobox be readonly?
97
228
  */
98
229
  set readonly(value: boolean) {
99
- if (typeof value === 'boolean' && value !== this.#state.readonly) {
100
- setReadonly(this, this.#state, value);
230
+ if (typeof value === 'boolean' && value !== this.readonly) {
231
+ setReadonly(this.#state, value);
101
232
  }
102
233
  }
103
234
 
@@ -105,15 +236,15 @@ export class OuiComboboxElement extends HTMLElement {
105
236
  * Is the combobox required?
106
237
  */
107
238
  get required(): boolean {
108
- return this.hasAttribute('required');
239
+ return this.#state?.required ?? false;
109
240
  }
110
241
 
111
242
  /**
112
243
  * Should the combobox be required?
113
244
  */
114
245
  set required(value: boolean) {
115
- if (typeof value === 'boolean' && value !== this.#state.required) {
116
- setRequired(this, this.#state, value);
246
+ if (typeof value === 'boolean' && value !== this.required) {
247
+ setRequired(this.#state, value);
117
248
  }
118
249
  }
119
250
 
@@ -121,7 +252,7 @@ export class OuiComboboxElement extends HTMLElement {
121
252
  * Selected options
122
253
  */
123
254
  get selected(): HTMLElement[] {
124
- return this.#state.selected.slice();
255
+ return this.#state.options.selected.slice();
125
256
  }
126
257
 
127
258
  /**
@@ -135,58 +266,53 @@ export class OuiComboboxElement extends HTMLElement {
135
266
  * Current value
136
267
  */
137
268
  get value(): string {
138
- return this.#state.values[0];
269
+ return this.#state.values.current[0];
139
270
  }
140
271
 
141
272
  /**
142
273
  * Set the value
143
274
  */
144
275
  set value(value: unknown) {
145
- const [option, all] = getOption(this.#state, value);
146
-
147
- if (option != null) {
148
- setNext('set', this, this.#state, all, option);
149
- }
276
+ setValue(this.#state, value);
150
277
  }
151
278
 
152
279
  /**
153
280
  * Current values
154
281
  */
155
282
  get values(): string[] {
156
- return this.#state.values.slice();
283
+ return this.#state.values.current.slice();
157
284
  }
158
285
 
159
286
  /**
160
287
  * Set the values
161
288
  */
162
289
  set values(value: unknown[]) {
163
- setValues(this, this.#state, value);
290
+ setValues(this.#state, value);
164
291
  }
165
292
 
166
293
  constructor() {
167
294
  super();
168
295
 
169
296
  this.#internals = this.attachInternals();
170
- this.#state = getState(this, this.#internals);
171
-
172
- comboboxes.set(this.#state.content, this);
173
-
174
- states.set(this, this.#state);
175
- states.set(this.#state.content, this.#state);
176
297
  }
177
298
 
178
299
  attributeChangedCallback(name: string, _: string | null, value: string | null): void {
300
+ if (this.#state == null) {
301
+ return;
302
+ }
303
+
179
304
  switch (name) {
180
- case 'disabled':
181
- case 'readonly':
182
305
  case 'required':
183
- case 'value':
184
306
  this[name] = this.hasAttribute(name);
185
307
  break;
186
308
 
187
309
  case 'max':
188
310
  case 'min':
189
- setLimit(name, this, this.#state, value == null ? undefined : Number.parseInt(value, 10));
311
+ setLimit(name, this.#state, value);
312
+ break;
313
+
314
+ case 'value':
315
+ this.value = value ?? '';
190
316
  break;
191
317
 
192
318
  default:
@@ -194,418 +320,668 @@ export class OuiComboboxElement extends HTMLElement {
194
320
  }
195
321
  }
196
322
 
323
+ /**
324
+ * Clear all selected options
325
+ */
197
326
  clear(): void {
198
- setValues(this, this.#state, []);
327
+ setValues(this.#state, []);
328
+ }
329
+
330
+ connectedCallback(): void {
331
+ if (this.#state == null) {
332
+ this.#initialize();
333
+ } else {
334
+ attachCombobox(this.#state);
335
+ }
336
+ }
337
+
338
+ disconnectedCallback(): void {
339
+ if (this.#state != null) {
340
+ detachCombobox(this.#state);
341
+ }
342
+ }
343
+
344
+ formDisabledCallback(disabled: boolean): void {
345
+ this.disabled = disabled;
199
346
  }
200
347
 
201
348
  formResetCallback(): void {
202
- this.#state.touched = false;
349
+ setValues(this.#state, this.#state.values.initial, false);
350
+ }
203
351
 
204
- this.clear();
352
+ formStateRestoreCallback(state: string | File | FormData, reason: unknown): void {
353
+ if (reason !== 'restore') {
354
+ return;
355
+ }
356
+
357
+ const parsed = typeof state === 'string' ? parse(state) : undefined;
358
+
359
+ if (!Array.isArray(parsed)) {
360
+ return;
361
+ }
362
+
363
+ if (this.#state == null) {
364
+ this.#restored = parsed;
365
+ } else {
366
+ setValues(this.#state, parsed);
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Set combobox localizations for a specific language
372
+ *
373
+ * @param language Language code
374
+ * @param localizations Localizations for language
375
+ */
376
+ setLocalizations(language: string, localizations: Partial<OuiComboboxLocalizations>): void;
377
+
378
+ /**
379
+ * Set combobox localizations for one or more languages
380
+ *
381
+ * @param localizations Localizations for one or more languages
382
+ */
383
+ setLocalizations(localizations: Record<string, Partial<OuiComboboxLocalizations>>): void;
384
+
385
+ setLocalizations(
386
+ first: string | Record<string, Partial<OuiComboboxLocalizations>>,
387
+ second?: Partial<OuiComboboxLocalizations>,
388
+ ): void {
389
+ updateLocalizations(
390
+ typeof first === 'string'
391
+ ? {
392
+ [first]: second!,
393
+ }
394
+ : first,
395
+ this.#state.localizations,
396
+ );
397
+ }
398
+
399
+ #initialize(): void {
400
+ this.tabIndex = -1;
401
+
402
+ this.#state = createState(this, this.#internals);
403
+
404
+ if (this.#restored != null) {
405
+ setValues(this.#state, this.#restored, false);
406
+
407
+ this.#restored = undefined;
408
+ }
205
409
  }
206
410
  }
207
411
 
208
- type OuiComboboxAutoComplete = Record<OuiComboboxAutoCompleteType, boolean> & {
209
- value: OuiComboboxAutoCompleteType;
412
+ type OuiComboboxFilter = {
413
+ element: HTMLInputElement;
414
+ value: string;
210
415
  };
211
416
 
212
- type OuiComboboxAutoCompleteType = 'both' | 'inline' | 'list' | 'none';
417
+ type OuiComboboxGroup = {
418
+ element: HTMLElement;
419
+ header: HTMLElement;
420
+ options: HTMLElement[];
421
+ };
422
+
423
+ type OuiComboboxLabel = {
424
+ element: HTMLLabelElement;
425
+ id: string;
426
+ };
427
+
428
+ type OuiComboboxLocalizations = {
429
+ empty: string | (() => string);
430
+ input: string | (() => string);
431
+ invalid: string | (() => string);
432
+ max: string | ((max: number) => string);
433
+ min: string | ((min: number) => string);
434
+ remove: string | ((label: string, value: string) => string);
435
+ results: string | ((amount: number) => string);
436
+ selected: string | ((amount: number) => string);
437
+ };
438
+
439
+ type OuiComboboxOptions = {
440
+ all: HTMLElement[];
441
+ selected: HTMLElement[];
442
+ };
443
+
444
+ type OuiComboboxPreview = 'none' | 'set' | 'typed';
445
+
446
+ type OuiComboboxSelection = {
447
+ container: HTMLElement;
448
+ items: OuiComboboxSelectionItem[];
449
+ };
450
+
451
+ type OuiComboboxSelectionItem = {
452
+ button: HTMLButtonElement;
453
+ element: HTMLElement;
454
+ };
213
455
 
214
456
  type OuiComboboxState = {
215
457
  active?: HTMLElement;
216
- anchor: HTMLElement;
458
+ anchor: OuiComboboxAnchor;
217
459
  autocomplete: OuiComboboxAutoComplete;
218
460
  content: HTMLElement;
219
461
  disabled: boolean;
220
- empty: HTMLElement;
221
- filter: string;
462
+ filter: OuiComboboxFilter;
222
463
  floatable: OuiFloatable;
464
+ groups?: OuiComboboxGroup[];
465
+ host: OuiComboboxElement;
223
466
  id: string;
224
467
  internals: ElementInternals;
225
- label: HTMLLabelElement;
468
+ label: OuiComboboxLabel;
226
469
  listeners: RemovableEventListener[];
470
+ localizations: Record<string, Partial<OuiComboboxLocalizations>>;
227
471
  max?: number;
228
472
  min?: number;
473
+ message: HTMLElement;
229
474
  name: string;
230
475
  multiple: boolean;
476
+ options: OuiComboboxOptions;
231
477
  readonly: boolean;
232
478
  required: boolean;
233
479
  select: HTMLSelectElement;
234
- selected: HTMLElement[];
235
- touched: boolean;
480
+ selection?: OuiComboboxSelection;
236
481
  type: OuiComboboxType;
237
- values: string[];
482
+ values: OuiComboboxValues;
238
483
  };
239
484
 
240
485
  type OuiComboboxType = 'autocomplete' | 'select';
241
486
 
242
- type OuiComboboxUpdate = 'navigation' | 'none' | 'remove' | 'set' | 'typed';
487
+ type OuiComboboxValues = {
488
+ current: string[];
489
+ initial: string[];
490
+ };
243
491
 
244
492
  // #endregion
245
493
 
246
494
  // #region Functions
247
495
 
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
- }
496
+ function applySelection(parameters: ApplySelectionParameters): void {
497
+ const {added, emit, option, selected, state} = parameters;
256
498
 
257
- const filter = state.filter.toLocaleLowerCase();
499
+ const {length} = state.options.all;
258
500
 
259
- const all = options ?? getOptions(state)[0];
260
- const {length} = all;
501
+ for (let index = 0; index < length; index += 1) {
502
+ const option = state.options.all[index];
261
503
 
262
- const visible: HTMLElement[] = [];
504
+ setAria(option, 'selected', (selected ?? state.options.selected).includes(option));
505
+ }
263
506
 
264
- for (let index = 0; index < length; index += 1) {
265
- const option = all[index];
507
+ const values =
508
+ parameters.values ??
509
+ (state.autocomplete.none
510
+ ? [state.filter.element.value]
511
+ : (selected ?? state.options.selected).map(option => getData(option, 'value', false) ?? ''));
266
512
 
267
- option.hidden = option.textContent?.toLocaleLowerCase().includes(filter) === false;
513
+ if (values.length === 0) {
514
+ values.push('');
515
+ }
268
516
 
269
- if (!option.hidden) {
270
- visible.push(option);
271
- }
517
+ if (selected != null) {
518
+ state.options.selected = selected;
272
519
  }
273
520
 
274
- state.empty.hidden = visible.length > 0;
521
+ const update =
522
+ values.length === state.values.current.length
523
+ ? values.some(value => !state.values.current.includes(value))
524
+ : true;
275
525
 
276
- if (state.empty.hidden) {
277
- return state.active != null && visible.includes(state.active) ? state.active : visible[0];
526
+ if (!update) {
527
+ return;
278
528
  }
529
+
530
+ updateForm(state, values, emit);
531
+ updatePreview('set', state, option);
532
+ updateSelection(state, option, added, selected);
279
533
  }
280
534
 
281
- function generateIds(select: HTMLSelectElement, prefix: string): void {
282
- const options = [...select.options];
283
- const {length} = options;
535
+ function attachCombobox(state: OuiComboboxState): void {
536
+ const {anchor, content, filter, label, listeners, select} = state;
284
537
 
285
- for (let index = 0; index < length; index += 1) {
286
- const option = options[index];
538
+ label.element.htmlFor = filter.element.id;
539
+ label.element.id = `${state.id}_label`;
287
540
 
288
- if (option.id === '') {
289
- option.id = `${prefix}_option_${index}`;
290
- }
291
- }
292
- }
541
+ select.hidden = true;
293
542
 
294
- function getAnchor(
295
- type: OuiComboboxType,
296
- id: string,
297
- label: HTMLLabelElement,
298
- select: HTMLSelectElement,
299
- autocomplete: OuiComboboxAutoComplete,
300
- multiple: boolean,
301
- ): HTMLElement {
302
- const anchor = type === 'autocomplete' ? getInput(id, select) : getBox(id, select, multiple);
543
+ select.insertAdjacentElement('afterend', anchor.container ?? anchor.element);
544
+ (anchor.container ?? anchor.element).insertAdjacentElement('afterend', content);
303
545
 
304
- setAria(anchor, {
305
- autocomplete: type === 'autocomplete' ? autocomplete.value : undefined,
306
- controls: `${id}_content`,
307
- expanded: false,
308
- haspopup: 'listbox',
309
- labelledby: label.id,
546
+ select.remove();
547
+
548
+ state.floatable = createEmbeddedFloatable(anchor.container ?? anchor.element, content, {
549
+ position: 'below-start',
550
+ reusable: false,
310
551
  });
311
552
 
312
- setAttribute(anchor, ATTRIBUTE_ANCHOR, '');
553
+ state.floatable.update();
313
554
 
314
- setRole(anchor, 'combobox');
555
+ listeners.push(
556
+ on(state.host, 'focusout', () => {
557
+ setTimeout(() => {
558
+ onFocusout(state);
559
+ });
560
+ }),
561
+ on(content, 'click', event => {
562
+ onClickContent(event, content);
563
+ }),
564
+ on(content, 'toggle', () => {
565
+ setAria(anchor.element, 'expanded', state.floatable.open);
315
566
 
316
- return anchor;
317
- }
567
+ setAria(
568
+ filter.element,
569
+ 'activedescendant',
570
+ state.floatable.open ? state.active?.id : undefined,
571
+ );
318
572
 
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);
573
+ if (state.floatable.open) {
574
+ filter.element.focus();
575
+ }
576
+ }),
577
+ );
326
578
 
327
- return possible.has(value as Value) ? (value as Value) : defaultValue;
579
+ states.map.set(state.host, state);
580
+ states.map.set(state.content, state);
581
+ states.set.add(state);
328
582
  }
329
583
 
330
- function getAutoComplete(element: OuiComboboxElement, select: boolean): OuiComboboxAutoComplete {
331
- let value: OuiComboboxAutoCompleteType;
584
+ function createAnchor(parameters: CreatAnchorParameters): CreateAnchor {
585
+ const {autocomplete, id, label, select, type} = parameters;
332
586
 
333
- if (select) {
334
- value = 'list';
587
+ let container: HTMLElement | undefined;
588
+ let element: HTMLElement;
589
+ let selection: OuiComboboxSelection | undefined;
590
+
591
+ if (parameters.type === 'autocomplete') {
592
+ element = createInput({
593
+ id,
594
+ label,
595
+ type,
596
+ autocomplete: autocomplete.value,
597
+ value: select.selectedOptions[0]?.textContent ?? '',
598
+ });
335
599
  } else {
336
- value = getAttributeValue(element, 'autocomplete', AUTOCOMPLETES, 'none');
600
+ ({container, element, selection} = createButton(parameters));
337
601
  }
338
602
 
339
- return {
340
- value,
341
- both: value === 'both',
342
- inline: value === 'inline',
343
- list: value === 'list',
344
- none: value === 'none',
345
- };
603
+ setAttribute(container ?? element, ATTRIBUTE_ANCHOR, '');
604
+
605
+ return {container, element, selection};
346
606
  }
347
607
 
348
- function getBox(id: string, select: HTMLSelectElement, multiple: boolean): HTMLElement {
349
- const box = createElement('div', {
350
- id,
351
- tabIndex: 0,
352
- textContent: multiple ? '' : (select.selectedOptions[0]?.textContent ?? ''),
608
+ function createButton(parameters: CreatAnchorParameters): CreateAnchor {
609
+ const {id, label, multiple, select} = parameters;
610
+
611
+ const element = createElement('button', {
612
+ aria: {
613
+ controls: `${id}_content`,
614
+ expanded: false,
615
+ haspopup: 'listbox',
616
+ labelledby: `${label.id} ${id}`,
617
+ },
618
+ property: {
619
+ id,
620
+ textContent: multiple ? '' : (select.selectedOptions[0]?.textContent ?? ''),
621
+ },
353
622
  });
354
623
 
355
- if (multiple) {
356
- const selection = createElement(
357
- 'ul',
358
- {
359
- // tabIndex: 0, TODO: handle navigation
360
- },
361
- {
362
- [ATTRIBUTE_SELECTION]: '',
363
- },
364
- );
624
+ if (!multiple) {
625
+ return {element};
626
+ }
365
627
 
366
- for (const option of select.selectedOptions) {
367
- selection.append(getSelectionItem(option));
368
- }
628
+ const container = createElement('div');
629
+ const selection = createSelection(id, select);
369
630
 
370
- box.append(selection);
371
- }
631
+ container.append(selection.container, element);
372
632
 
373
- return box;
633
+ return {container, element, selection};
374
634
  }
375
635
 
376
- function getContent(
377
- id: string,
378
- label: HTMLLabelElement,
379
- select: HTMLSelectElement,
380
- autocomplete: OuiComboboxAutoComplete,
381
- multiple: boolean,
382
- ): [HTMLElement, HTMLElement[], HTMLElement] {
383
- const content = createElement(
384
- 'div',
385
- {
386
- id: `${id}_content`,
636
+ function createContent(parameters: CreateContentParameters): CreateContent {
637
+ const {autocomplete, id, label, multiple, select, type} = parameters;
638
+
639
+ const element = createElement('div', {
640
+ aria: {
641
+ labelledby: label.id,
387
642
  },
388
- {
643
+ attribute: {
389
644
  [ATTRIBUTE_CONTENT]: '',
390
645
  popover: '',
391
- tabindex: -1,
392
- 'aria-labelledby': label.id,
393
- },
394
- );
395
-
396
- const list = createElement(
397
- 'ul',
398
- {
399
- role: 'listbox',
400
646
  },
401
- {
402
- [ATTRIBUTE_LIST]: '',
403
- 'aria-labelledby': label.id,
404
- 'aria-multiselectable': multiple,
647
+ property: {
648
+ id: `${id}_content`,
649
+ tabIndex: -1,
405
650
  },
406
- );
651
+ });
407
652
 
408
- const options = [...select.options];
653
+ const filter = createFilter({id, label, type, autocomplete: autocomplete.value});
654
+ const list = createList({autocomplete, id, label, multiple, select});
655
+ const message = createMessage();
409
656
 
410
- const selected: HTMLElement[] = [];
657
+ if (filter != null) {
658
+ element.append(filter.container);
659
+ }
411
660
 
412
- for (let index = 0; index < options.length; index += 1) {
413
- const option = options[index];
661
+ element.append(list.element, message);
414
662
 
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
- );
663
+ return {element, filter, list, message};
664
+ }
429
665
 
430
- if (option.selected) {
431
- selected.push(item);
432
- }
666
+ function createFilter(parameters: CreateFilterParameters): CreateFilter | undefined {
667
+ const {autocomplete, id, label, type} = parameters;
433
668
 
434
- list.append(item);
669
+ if (type === 'autocomplete') {
670
+ return;
435
671
  }
436
672
 
437
- const empty = createElement(
438
- 'div',
439
- {
440
- hidden: true,
441
- role: 'status',
442
- textContent: 'No results found',
673
+ const container = createElement('div', {
674
+ attribute: {
675
+ [ATTRIBUTE_FILTER]: '',
443
676
  },
444
- {
445
- [ATTRIBUTE_EMPTY]: '',
446
- 'aria-live': 'polite',
447
- },
448
- );
449
-
450
- content.append(list, empty);
451
-
452
- return [content, selected, empty];
453
- }
677
+ });
454
678
 
455
- function getFormValue(state: OuiComboboxState, values: string[]): string {
456
- if (values.length === 0 || !state.multiple) {
457
- return values[0];
458
- }
679
+ const element = createInput({autocomplete, id, label, type, value: ''});
459
680
 
460
- let formValues: string[] = [];
681
+ setAttribute(element, ATTRIBUTE_FILTER_INPUT, '');
461
682
 
462
- for (const value of values) {
463
- formValues.push(`${state.name}=${value}`);
464
- }
683
+ container.append(element);
465
684
 
466
- return join(formValues, '&');
685
+ return {container, element};
467
686
  }
468
687
 
469
- function getFormValues(state: OuiComboboxState, added?: boolean): [string[], boolean] {
470
- let values: string[];
688
+ function createGroup(parameters: CreateGroupParameters): OuiComboboxGroup {
689
+ const {autocomplete, group, groupOptions, selected} = parameters;
471
690
 
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
- }
691
+ const element = createElement('li', {
692
+ aria: {
693
+ label: group.label,
694
+ },
695
+ property: {
696
+ role: 'group',
697
+ },
698
+ });
480
699
 
481
- const previous = getFormValue(state, state.values);
482
- const next = getFormValue(state, values);
700
+ const list = createElement('ul', {
701
+ property: {
702
+ role: 'none',
703
+ },
704
+ });
483
705
 
484
- return [values, next !== previous];
485
- }
706
+ const header = createHeader(group);
486
707
 
487
- function getId(): string {
488
- index += 1;
708
+ element.append(header, list);
489
709
 
490
- return `oui_combobox_${index}`;
710
+ return {
711
+ element,
712
+ header,
713
+ options: createOptions({
714
+ autocomplete,
715
+ list,
716
+ selected,
717
+ group: header.id,
718
+ options: groupOptions,
719
+ }),
720
+ };
491
721
  }
492
722
 
493
- function getInput(id: string, select: HTMLSelectElement): HTMLInputElement {
494
- return createElement(
495
- 'input',
496
- {
497
- id,
498
- value: select.selectedOptions[0]?.textContent ?? '',
723
+ function createHeader(group: HTMLOptGroupElement): HTMLElement {
724
+ return createElement('div', {
725
+ aria: {
726
+ hidden: true,
499
727
  },
500
- {
501
- [ATTRIBUTE_ANCHOR]: '',
502
- autocomplete: 'off',
503
- type: 'text',
728
+ attribute: {
729
+ [ATTRIBUTE_HEADER]: '',
504
730
  },
505
- );
731
+ property: {
732
+ id: group.id,
733
+ textContent: group.label,
734
+ },
735
+ });
506
736
  }
507
737
 
508
- function getOption(
509
- state: OuiComboboxState,
510
- value: unknown,
511
- ): [HTMLElement | undefined, HTMLElement[]] {
512
- const [all] = getOptions(state);
513
- const valueAsString = getString(value).toLocaleLowerCase();
738
+ function createInput(parameters: CreateInputParameters): HTMLInputElement {
739
+ const {autocomplete, id, label, type, value} = parameters;
514
740
 
515
- filterOptions(state, all, true);
741
+ return createElement('input', {
742
+ aria: {
743
+ autocomplete: autocomplete,
744
+ controls: `${id}_list`,
745
+ expanded: type === 'select',
746
+ haspopup: 'listbox',
747
+ labelledby: label.id,
748
+ },
749
+ attribute: {
750
+ autocorrect: 'off',
751
+ autocomplete: 'off',
752
+ form: '',
753
+ spellcheck: 'false',
754
+ type: 'text',
755
+ },
756
+ property: {
757
+ id,
758
+ value,
759
+ role: 'combobox',
760
+ },
761
+ });
762
+ }
516
763
 
517
- let match: HTMLElement | undefined;
764
+ function createList(parameters: CreateListParameters): CreateList {
765
+ const {autocomplete, id, label, multiple, select} = parameters;
518
766
 
519
- for (const option of all) {
520
- if (getAttribute(option, 'value')?.toLocaleLowerCase() === valueAsString) {
521
- match = option;
767
+ const element = createElement('ul', {
768
+ aria: {
769
+ labelledby: label.id,
770
+ multiselectable: multiple,
771
+ },
772
+ attribute: {
773
+ [ATTRIBUTE_LIST]: '',
774
+ tabindex: -1,
775
+ },
776
+ property: {
777
+ id: `${id}_list`,
778
+ role: 'listbox',
779
+ },
780
+ });
522
781
 
523
- break;
782
+ const children = [...select.children];
783
+ const childrenLength = children.length;
784
+
785
+ const options: HTMLElement[] = [];
786
+ const selected: HTMLElement[] = [];
787
+
788
+ let groups: OuiComboboxGroup[] | undefined;
789
+
790
+ for (let childIndex = 0; childIndex < childrenLength; childIndex += 1) {
791
+ const child = children[childIndex];
792
+
793
+ if (child instanceof HTMLOptionElement) {
794
+ const option = createOption({
795
+ autocomplete,
796
+ index: childIndex,
797
+ option: child,
798
+ });
799
+
800
+ element.append(option);
801
+ options.push(option);
802
+
803
+ if (child.selected) {
804
+ selected.push(option);
805
+ }
806
+
807
+ continue;
524
808
  }
809
+
810
+ if (!(child instanceof HTMLOptGroupElement)) {
811
+ continue;
812
+ }
813
+
814
+ const group = createGroup({
815
+ autocomplete,
816
+ selected,
817
+ group: child,
818
+ groupOptions: [...child.children] as HTMLOptionElement[],
819
+ });
820
+
821
+ groups ??= [];
822
+
823
+ element.append(group.element, ...group.options);
824
+ groups.push(group);
825
+ options.push(...group.options);
525
826
  }
526
827
 
527
- return [match, all];
828
+ return {element, groups, options, selected};
528
829
  }
529
830
 
530
- function getOptions(state: OuiComboboxState): HTMLElement[][] {
531
- const all = [...state.content.querySelectorAll(`[${ATTRIBUTE_OPTION}]`)].filter(
532
- option => option instanceof HTMLElement,
533
- );
831
+ function createMessage(): HTMLElement {
832
+ return createElement('div', {
833
+ aria: {
834
+ live: 'polite',
835
+ },
836
+ attribute: {
837
+ [ATTRIBUTE_MESSAGE]: '',
838
+ },
839
+ property: {
840
+ hidden: true,
841
+ role: 'status',
842
+ },
843
+ });
844
+ }
845
+
846
+ function createOption(parameters: CreateOptionParameters): HTMLElement {
847
+ const {autocomplete, group, option} = parameters;
848
+
849
+ const item = createElement('li', {
850
+ aria: {
851
+ selected: !autocomplete.none && option.selected,
852
+ },
853
+ attribute: {
854
+ [ATTRIBUTE_OPTION]: '',
855
+ },
856
+ property: {
857
+ id: option.id,
858
+ role: 'option',
859
+ textContent: option.textContent,
860
+ },
861
+ });
534
862
 
535
- const visible = all.filter(option => !option.hidden);
863
+ setData(item, {
864
+ group,
865
+ value: option.value,
866
+ });
536
867
 
537
- return [all, visible];
868
+ return item;
538
869
  }
539
870
 
540
- function getSelected(
541
- update: OuiComboboxUpdate,
542
- state: OuiComboboxState,
543
- element: HTMLElement,
544
- next?: HTMLElement,
545
- ): boolean {
546
- if (state.autocomplete.none) {
547
- return false;
548
- }
871
+ function createOptions(parameters: CreateOptionsParameters): HTMLElement[] {
872
+ const {autocomplete, group, list, options, selected} = parameters;
873
+
874
+ const items: HTMLElement[] = [];
875
+
876
+ for (let index = 0; index < options.length; index += 1) {
877
+ const option = options[index];
878
+
879
+ const item = createOption({autocomplete, group, index, option});
549
880
 
550
- const selected = getAria(element, 'selected') === 'true';
881
+ items.push(item);
551
882
 
552
- if (update === 'navigation') {
553
- if (state.autocomplete.both) {
554
- return element === next;
883
+ if (option.selected) {
884
+ selected.push(item);
555
885
  }
556
886
 
557
- return selected;
887
+ list.append(item);
558
888
  }
559
889
 
560
- if (update === 'none') {
561
- return selected;
562
- }
890
+ return items;
891
+ }
563
892
 
564
- if (update === 'set') {
565
- if (next == null) {
566
- return selected;
567
- }
893
+ function createSelection(id: string, select: HTMLSelectElement): OuiComboboxSelection {
894
+ const container = createElement('ul', {
895
+ attribute: {
896
+ [ATTRIBUTE_SELECTION]: '',
897
+ },
898
+ property: {
899
+ id: `${id}_selection`,
900
+ },
901
+ });
568
902
 
569
- if (state.multiple) {
570
- return element === next ? !selected : selected;
571
- }
903
+ const options = [...select.selectedOptions];
904
+
905
+ const items: OuiComboboxSelectionItem[] = [];
906
+
907
+ createSelectionItems({container, items, options});
908
+
909
+ const {button} = items[0] ?? {};
572
910
 
573
- return element === next;
911
+ if (button != null) {
912
+ button.tabIndex = 0;
574
913
  }
575
914
 
576
- return selected;
915
+ return {
916
+ items,
917
+ container: container,
918
+ };
577
919
  }
578
920
 
579
- function getSelectionItem(option: HTMLElement): HTMLElement {
580
- const icon = createElement(
581
- 'span',
582
- {
583
- innerHTML: '&times;',
921
+ function createSelectionItem(
922
+ option: HTMLElement,
923
+ state?: OuiComboboxState,
924
+ ): OuiComboboxSelectionItem {
925
+ const element = createElement('li');
926
+
927
+ const localization = state == null ? option.textContent : getLocalization('remove', state);
928
+
929
+ const button = createElement('button', {
930
+ aria: {
931
+ label:
932
+ typeof localization === 'function'
933
+ ? localization(option.textContent ?? '', getData(option, 'value', false) ?? '')
934
+ : localization,
584
935
  },
585
- {
586
- 'aria-hidden': true,
936
+ attribute: {
937
+ [ATTRIBUTE_SELECTION_ITEM]: '',
938
+ tabindex: -1,
587
939
  },
588
- );
940
+ data: {
941
+ option: option.id,
942
+ },
943
+ });
589
944
 
590
- const label = createElement('span', {
591
- textContent: option.textContent,
945
+ const icon = createElement('span', {
946
+ aria: {
947
+ hidden: true,
948
+ },
949
+ property: {
950
+ innerHTML: '&times;',
951
+ },
592
952
  });
593
953
 
594
- const item = createElement(
595
- 'li',
596
- {},
597
- {
598
- [ATTRIBUTE_SELECTION_ITEM]: '',
599
- option: option.id,
954
+ const label = createElement('span', {
955
+ property: {
956
+ textContent: option.textContent,
600
957
  },
601
- );
958
+ });
602
959
 
603
- item.append(icon, label);
960
+ button.append(icon, label);
961
+ element.append(button);
604
962
 
605
- return item;
963
+ return {
964
+ button,
965
+ element,
966
+ };
606
967
  }
607
968
 
608
- function getState(element: OuiComboboxElement, internals: ElementInternals): OuiComboboxState {
969
+ function createSelectionItems(parameters: CreateSelectionItemsParameters): void {
970
+ const {container, items, options, state} = parameters;
971
+
972
+ const {length} = options;
973
+
974
+ for (let index = 0; index < length; index += 1) {
975
+ const option = options[index];
976
+
977
+ const item = createSelectionItem(option, state);
978
+
979
+ container.append(item.element);
980
+ items.push(item);
981
+ }
982
+ }
983
+
984
+ function createState(element: OuiComboboxElement, internals: ElementInternals): OuiComboboxState {
609
985
  const select = element.querySelector('select');
610
986
 
611
987
  if (select == null || select.id === '') {
@@ -618,233 +994,340 @@ function getState(element: OuiComboboxElement, internals: ElementInternals): Oui
618
994
  throw new Error(MESSAGE_LABEL);
619
995
  }
620
996
 
621
- select.hidden = true;
622
-
623
997
  element.id = select.id;
624
998
 
625
999
  setAttribute(element, 'name', select.name === '' ? select.id : select.name);
626
1000
 
627
- const disabled = element.disabled || select.disabled;
1001
+ const disabled = getBooleanAria(element, 'disabled') || select.disabled;
628
1002
  const multiple = element.multiple || select.multiple;
629
- const readonly = element.readonly;
630
- const required = element.required || select.required;
1003
+ const readonly = getBooleanAria(element, 'readonly');
1004
+ const required = getBooleanAria(element, 'required') || select.required;
631
1005
 
632
1006
  const id = getId();
633
1007
  const type = multiple ? 'select' : getAttributeValue(element, 'type', TYPES, 'select');
634
1008
 
635
1009
  const autocomplete = getAutoComplete(element, type === 'select');
636
1010
 
1011
+ const labelId = label.id;
1012
+
637
1013
  label.htmlFor = id;
638
1014
  label.id = `${id}_label`;
639
1015
 
640
- generateIds(select, id);
641
-
642
- const anchor = getAnchor(type, id, label, select, autocomplete, multiple);
1016
+ generateIds(id, select);
643
1017
 
644
- const [content, selected, empty] = getContent(id, label, select, autocomplete, multiple);
1018
+ const anchor = createAnchor({autocomplete, id, label, multiple, select, type});
1019
+ const content = createContent({autocomplete, id, label, multiple, select, type});
645
1020
 
646
1021
  const values =
647
- selected.length === 0 ? [''] : selected.map(option => getAttribute(option, 'value') ?? '');
648
-
649
- const floatable = createEmbeddedFloatable(anchor, content, {
650
- position: 'below-start',
651
- reusable: false,
652
- });
653
-
654
- floatable.update();
655
-
656
- select.insertAdjacentElement('afterend', anchor);
657
- anchor.insertAdjacentElement('afterend', content);
658
-
659
- select.remove();
1022
+ content.list.selected.length === 0
1023
+ ? ['']
1024
+ : content.list.selected.map(option => getData(option, 'value', false) ?? '');
660
1025
 
661
1026
  const state: OuiComboboxState = {
662
- anchor,
663
1027
  autocomplete,
664
- content,
665
1028
  disabled,
666
- empty,
667
- floatable,
668
1029
  id,
669
1030
  internals,
670
- label,
671
1031
  multiple,
672
1032
  readonly,
673
1033
  required,
674
1034
  select,
675
- selected,
676
1035
  type,
677
- values,
678
- filter: '',
1036
+ anchor: {
1037
+ container: anchor.container,
1038
+ element: anchor.element,
1039
+ },
1040
+ active: content.list.selected[0],
1041
+ content: content.element,
1042
+ filter: {
1043
+ element: content.filter?.element ?? (anchor.element as HTMLInputElement),
1044
+ value: '',
1045
+ },
1046
+ floatable: undefined as never,
1047
+ groups: content.list.groups,
1048
+ host: element,
1049
+ label: {
1050
+ element: label,
1051
+ id: labelId,
1052
+ },
679
1053
  listeners: [],
1054
+ localizations: {},
1055
+ message: content.message,
680
1056
  name: element.name,
681
- touched: false,
1057
+ options: {
1058
+ all: content.list.options,
1059
+ selected: content.list.selected,
1060
+ },
1061
+ selection: anchor.selection,
1062
+ values: {
1063
+ current: values.slice(),
1064
+ initial: values.slice(),
1065
+ },
682
1066
  };
683
1067
 
684
- state.listeners.push(
685
- on(anchor, 'blur', () => {
686
- state.touched = true;
1068
+ attachCombobox(state);
687
1069
 
688
- updateValidity(state);
689
- }),
690
- on(content, 'click', event => {
691
- onContent(event, content);
692
- }),
693
- on(content, 'toggle', () => {
694
- setAria(anchor, {
695
- activedescendant: floatable.open ? state.active?.id : undefined,
696
- expanded: floatable.open,
697
- });
1070
+ setDisabled(state, disabled);
1071
+ setLimit('max', state, getAttribute(element, 'max'));
1072
+ setLimit('min', state, getAttribute(element, 'min'));
1073
+ setReadonly(state, readonly);
1074
+ setRequired(state, required);
698
1075
 
699
- if (floatable.open) {
700
- updateWidth(state);
701
- }
702
- }),
703
- );
1076
+ filterOptions(state);
704
1077
 
705
- setDisabled(element, state, disabled);
1078
+ if (content.list.selected.length > 0) {
1079
+ updatePreview('set', state, content.list.selected[0]);
1080
+ }
1081
+
1082
+ updateForm(state, state.values.current, false);
1083
+ updateMessage(state, getVisible(state).length);
706
1084
 
707
1085
  return state;
708
1086
  }
709
1087
 
710
- function hasTextSelection(element: HTMLElement): element is HTMLInputElement {
711
- return element instanceof HTMLInputElement && element.selectionStart !== element.selectionEnd;
712
- }
1088
+ function detachCombobox(state: OuiComboboxState): void {
1089
+ const {content, floatable, host, label, select} = state;
713
1090
 
714
- function onAnchor(
715
- event: KeyboardEvent,
716
- handler: (event: KeyboardEvent, combobox: OuiComboboxElement, state: OuiComboboxState) => void,
717
- ): void {
718
- if (event.ctrlKey || event.metaKey || event.shiftKey) {
719
- return;
1091
+ const listeners = state.listeners.splice(0);
1092
+ const {length} = listeners;
1093
+
1094
+ for (let index = 0; index < length; index += 1) {
1095
+ listeners[index]();
720
1096
  }
721
1097
 
722
- const combobox = findAncestor(event, TAGNAME);
723
- const state = states.get(combobox!);
1098
+ floatable.destroy();
724
1099
 
725
- if (combobox == null || state == null) {
726
- return;
1100
+ const anchor = state.anchor.container ?? state.anchor.element;
1101
+
1102
+ anchor.insertAdjacentElement('beforebegin', select);
1103
+
1104
+ anchor.remove();
1105
+ content.remove();
1106
+
1107
+ label.element.htmlFor = select.id;
1108
+ label.element.id = state.label.id;
1109
+
1110
+ select.hidden = false;
1111
+
1112
+ states.map.delete(host);
1113
+ states.map.delete(content);
1114
+ states.set.delete(state);
1115
+ }
1116
+
1117
+ function filterOptions(state: OuiComboboxState): HTMLElement | undefined {
1118
+ function filter(options: HTMLElement[]): boolean {
1119
+ const {length} = options;
1120
+
1121
+ let count = 0;
1122
+
1123
+ for (let index = 0; index < length; index += 1) {
1124
+ const option = options[index];
1125
+
1126
+ option.hidden =
1127
+ !state.autocomplete.none && !option.textContent.toLocaleLowerCase().includes(value);
1128
+
1129
+ if (!option.hidden) {
1130
+ count += 1;
1131
+
1132
+ visible.push(option);
1133
+ }
1134
+ }
1135
+
1136
+ return count === 0;
727
1137
  }
728
1138
 
729
- const anchor = findAncestor(event, SELECTOR_ANCHOR);
1139
+ const value = state.filter.element.value.toLocaleLowerCase();
1140
+
1141
+ const visible: HTMLElement[] = [];
1142
+
1143
+ if (state.groups == null) {
1144
+ filter(state.options.all);
1145
+ } else {
1146
+ const groupsLength = state.groups.length;
1147
+
1148
+ for (let groupIndex = 0; groupIndex < groupsLength; groupIndex += 1) {
1149
+ const {element, options} = state.groups[groupIndex];
730
1150
 
731
- if (anchor != null) {
732
- handler(event, combobox, state);
1151
+ element.hidden = filter(options);
1152
+ }
733
1153
  }
1154
+
1155
+ updateMessage(state, visible.length);
1156
+
1157
+ return state.active != null && visible.includes(state.active) ? state.active : visible[0];
734
1158
  }
735
1159
 
736
- function onAnchorKeydown(
737
- event: KeyboardEvent,
738
- combobox: OuiComboboxElement,
1160
+ function filterOption(
739
1161
  state: OuiComboboxState,
740
- ): void {
741
- const isSelect = state.type === 'select';
1162
+ value: unknown,
1163
+ textContent?: boolean,
1164
+ ): HTMLElement | undefined {
1165
+ const filterTextContent = textContent ?? false;
742
1166
 
743
- let preventDefault = true;
1167
+ const filter = getString(value).toLocaleLowerCase();
744
1168
 
745
- switch (true) {
746
- case event.key === ' ' && state.type === 'select':
747
- case event.key === 'Enter':
748
- onEnter(combobox, state);
749
- break;
1169
+ filterOptions(state);
750
1170
 
751
- case event.key === 'Escape':
752
- onEscape(state);
753
- break;
1171
+ const {length} = state.options.all;
1172
+ let match: HTMLElement | undefined;
754
1173
 
755
- case isSelect && KEYS_ABSOLUTE.has(event.key):
756
- onNavigation(event, combobox, state);
757
- break;
1174
+ for (let index = 0; index < length; index += 1) {
1175
+ const option = state.options.all[index];
758
1176
 
759
- case !isSelect && KEYS_SELECTION.has(event.key): {
760
- preventDefault = false;
1177
+ const value = (
1178
+ filterTextContent ? option.textContent : (getData(option, 'value', false) ?? '')
1179
+ ).toLocaleLowerCase();
761
1180
 
762
- if (hasTextSelection(state.anchor)) {
763
- onSelection(combobox, state);
764
- }
1181
+ if (value === filter) {
1182
+ match = option;
765
1183
 
766
1184
  break;
767
1185
  }
1186
+ }
768
1187
 
769
- case KEYS_NAVIGATION.has(event.key):
770
- onNavigation(event, combobox, state);
771
- break;
1188
+ return match;
1189
+ }
772
1190
 
773
- default: {
774
- preventDefault = false;
1191
+ function generateIds(prefix: string, select: HTMLSelectElement): void {
1192
+ const elements = [...select.children].flatMap(child =>
1193
+ child instanceof HTMLOptGroupElement
1194
+ ? [
1195
+ child,
1196
+ ...[...child.children].filter(groupChild => groupChild instanceof HTMLOptionElement),
1197
+ ]
1198
+ : child instanceof HTMLOptionElement
1199
+ ? [child]
1200
+ : [],
1201
+ );
775
1202
 
776
- if (event.key === 'Tab') {
777
- onTab(combobox, state);
778
- }
1203
+ const {length} = elements;
779
1204
 
780
- break;
1205
+ for (let index = 0; index < length; index += 1) {
1206
+ const element = elements[index];
1207
+
1208
+ if (element.id === '') {
1209
+ element.id = `${prefix}${element instanceof HTMLOptGroupElement ? '_group_' : '_option_'}${index}`;
781
1210
  }
782
1211
  }
1212
+ }
783
1213
 
784
- if (preventDefault) {
785
- event.preventDefault();
786
- event.stopPropagation();
1214
+ function getAttributeValue<Value>(
1215
+ element: HTMLElement,
1216
+ attribute: string,
1217
+ possible: Set<Value>,
1218
+ defaultValue: Value,
1219
+ ): Value {
1220
+ const value = getAttribute(element, attribute);
1221
+
1222
+ return possible.has(value as Value) ? (value as Value) : defaultValue;
1223
+ }
1224
+
1225
+ function getAutoComplete(element: OuiComboboxElement, select: boolean): OuiComboboxAutoComplete {
1226
+ let value: OuiComboboxAutoCompleteType;
1227
+
1228
+ if (select) {
1229
+ value = 'list';
1230
+ } else {
1231
+ value = getAttributeValue(element, 'autocomplete', AUTOCOMPLETES, 'none');
787
1232
  }
1233
+
1234
+ return {
1235
+ value,
1236
+ both: value === 'both',
1237
+ inline: value === 'inline',
1238
+ list: value === 'list',
1239
+ none: value === 'none',
1240
+ };
788
1241
  }
789
1242
 
790
- function onAnchorKeyup(
791
- event: KeyboardEvent,
792
- combobox: OuiComboboxElement,
793
- state: OuiComboboxState,
794
- ): void {
795
- const typed = event.key.length === 1 && EXPRESSION_TYPED.test(event.key);
1243
+ function getBooleanAria(element: HTMLElement, attribute: AnyAriaBooleanAttribute): boolean {
1244
+ const value = getAria(element, attribute);
796
1245
 
797
- if (state.type === 'select') {
798
- if (typed) {
799
- event.preventDefault();
800
- event.stopPropagation();
1246
+ return value === true || value === 'true';
1247
+ }
801
1248
 
802
- onScrollTo(event, combobox, state);
803
- }
1249
+ function getId(): string {
1250
+ index += 1;
804
1251
 
805
- return;
806
- }
1252
+ return `oui_combobox_${index}`;
1253
+ }
807
1254
 
808
- let preventDefault = true;
1255
+ function getLanguage(state: OuiComboboxState): string {
1256
+ return (
1257
+ state.host.closest('[lang]')?.getAttribute('lang') ?? document.documentElement.lang
1258
+ ).toLowerCase();
1259
+ }
809
1260
 
810
- if (typed) {
811
- state.filter += event.key;
812
- }
1261
+ function getLocalization<Key extends keyof OuiComboboxLocalizations>(
1262
+ key: Key,
1263
+ state: OuiComboboxState,
1264
+ ): OuiComboboxLocalizations[Key] {
1265
+ const language = getLanguage(state);
813
1266
 
814
- if (
815
- state.anchor instanceof HTMLInputElement &&
816
- (KEYS_REMOVE.has(event.key) || state.anchor.value.length < state.filter.length)
817
- ) {
818
- state.filter = state.anchor.value;
1267
+ const allLocalizations = [state.localizations, LOCALIZATIONS.globals];
1268
+ const {length} = allLocalizations;
819
1269
 
820
- filterOptions(state);
821
- }
1270
+ let localization: OuiComboboxLocalizations[Key] | undefined;
1271
+ let shortLanguage: string | undefined;
822
1272
 
823
- switch (true) {
824
- case typed:
825
- onTyped(combobox, state);
826
- break;
1273
+ for (let index = 0; index < length; index += 1) {
1274
+ const localizations = allLocalizations[index];
1275
+
1276
+ localization = localizations?.[language]?.[key];
827
1277
 
828
- case KEYS_REMOVE.has(event.key):
829
- onRemove(combobox, state);
1278
+ if (localization != null) {
830
1279
  break;
1280
+ }
831
1281
 
832
- default: {
833
- preventDefault = false;
1282
+ shortLanguage ??= language.split('-')[0];
1283
+
1284
+ if (shortLanguage === language) {
1285
+ continue;
1286
+ }
1287
+
1288
+ localization = localizations?.[shortLanguage]?.[key];
834
1289
 
1290
+ if (localization != null) {
835
1291
  break;
836
1292
  }
837
1293
  }
838
1294
 
839
- if (preventDefault) {
840
- event.preventDefault();
841
- event.stopPropagation();
1295
+ return localization ?? LOCALIZATIONS.defaults[key];
1296
+ }
1297
+
1298
+ function getVisible(state: OuiComboboxState): HTMLElement[] {
1299
+ return state.options.all.filter(option => !option.hidden);
1300
+ }
1301
+
1302
+ function hasTextSelection(element: HTMLElement): element is HTMLInputElement {
1303
+ return element instanceof HTMLInputElement && element.selectionStart !== element.selectionEnd;
1304
+ }
1305
+
1306
+ function onClickContent(event: PointerEvent, content: HTMLElement): void {
1307
+ const state = states.map.get(content);
1308
+
1309
+ if (state == null) {
1310
+ return;
1311
+ }
1312
+
1313
+ if (state.disabled) {
1314
+ return;
1315
+ }
1316
+
1317
+ const option = findAncestor(event, SELECTOR_OPTION);
1318
+
1319
+ if (!(option instanceof HTMLElement)) {
1320
+ state.filter.element.focus();
1321
+ } else if (state.readonly) {
1322
+ updatePreview('set', state, option);
1323
+ } else {
1324
+ toggleOption(state, option);
842
1325
  }
843
1326
  }
844
1327
 
845
- function onClick(event: PointerEvent): void {
1328
+ function onClickDocument(event: PointerEvent): void {
846
1329
  const combobox = findAncestor(event, TAGNAME);
847
- const state = states.get(combobox!);
1330
+ const state = states.map.get(combobox!);
848
1331
 
849
1332
  if (combobox == null || state == null) {
850
1333
  return;
@@ -853,10 +1336,12 @@ function onClick(event: PointerEvent): void {
853
1336
  const label = findAncestor(event, 'label');
854
1337
 
855
1338
  if (label != null) {
856
- if (label === state.label && combobox.type === 'select') {
857
- state.anchor.focus();
1339
+ if (label === state.label.element && combobox.type === 'select') {
1340
+ state.anchor.element.focus();
858
1341
 
859
- state.floatable.show();
1342
+ if (!state.disabled) {
1343
+ state.floatable.show();
1344
+ }
860
1345
  }
861
1346
 
862
1347
  return;
@@ -865,72 +1350,273 @@ function onClick(event: PointerEvent): void {
865
1350
  const selection = findAncestor(event, `[${ATTRIBUTE_SELECTION_ITEM}]`);
866
1351
 
867
1352
  if (selection != null) {
868
- const option = state.selected.find(option => option.id === getAttribute(selection, 'option'));
1353
+ const option = state.options.selected.find(
1354
+ option => option.id === getData(selection, 'option', false),
1355
+ );
869
1356
 
870
- setValue('set', combobox, state, option);
1357
+ if (state.disabled) {
1358
+ state.anchor.element.focus();
1359
+ } else if (state.readonly) {
1360
+ updateTabindices(state, option);
1361
+ } else {
1362
+ toggleOption(state, option);
1363
+ }
871
1364
 
872
1365
  return;
873
1366
  }
874
1367
 
875
1368
  const anchor = findAncestor(event, SELECTOR_ANCHOR);
876
1369
 
877
- if (anchor != null) {
1370
+ if (anchor != null && !state.disabled) {
878
1371
  state.floatable.show();
879
1372
 
880
1373
  return;
881
1374
  }
882
1375
  }
883
1376
 
884
- function onContent(event: PointerEvent, content: HTMLElement): void {
885
- const combobox = comboboxes.get(content);
886
- const state = states.get(content);
1377
+ function onEnter(state: OuiComboboxState): void {
1378
+ if (state.disabled || state.readonly) {
1379
+ return;
1380
+ }
1381
+
1382
+ if (hasTextSelection(state.anchor.element)) {
1383
+ onSelection(state);
1384
+ } else {
1385
+ toggleOption(state, state.active);
1386
+ }
1387
+ }
1388
+
1389
+ function onEscape(state: OuiComboboxState): void {
1390
+ state.floatable.hide();
1391
+ }
1392
+
1393
+ function onFilter(
1394
+ type: InputEvent['inputType'],
1395
+ state: OuiComboboxState,
1396
+ manual?: boolean,
1397
+ emit?: boolean,
1398
+ ): void {
1399
+ if (state.disabled || state.readonly) {
1400
+ return;
1401
+ }
1402
+
1403
+ const diff = state.filter.value !== state.filter.element.value;
1404
+
1405
+ if (!state.autocomplete.inline && state.filter.element.value.length > 0 && !(manual ?? false)) {
1406
+ state.floatable.show();
1407
+ }
1408
+
1409
+ const option = state.autocomplete.none
1410
+ ? (state.active ?? state.options.all[0])
1411
+ : filterOptions(state);
1412
+
1413
+ let update: OuiComboboxPreview | undefined;
1414
+
1415
+ if (type.includes('delete')) {
1416
+ update = 'none';
1417
+ } else if (type.includes('insert')) {
1418
+ update = 'typed';
1419
+ }
1420
+
1421
+ if (update != null) {
1422
+ if (state.autocomplete.none) {
1423
+ applySelection({state, added: false, emit: (emit ?? true) && diff});
1424
+ } else {
1425
+ updatePreview(update, state, option);
1426
+ }
1427
+ }
1428
+
1429
+ if (diff) {
1430
+ state.filter.value = state.filter.element.value;
1431
+
1432
+ dispatch(state.host, 'input');
1433
+ }
1434
+ }
1435
+
1436
+ function onFocusout(state: OuiComboboxState): void {
1437
+ if (
1438
+ state.disabled ||
1439
+ state.readonly ||
1440
+ state.type === 'select' ||
1441
+ state.host === document.activeElement ||
1442
+ state.host.contains(document.activeElement)
1443
+ ) {
1444
+ return;
1445
+ }
1446
+
1447
+ const option = filterOption(state, state.filter.element.value, true);
1448
+
1449
+ applySelection({
1450
+ option,
1451
+ state,
1452
+ added: false,
1453
+ selected: option == null ? [] : [option],
1454
+ });
1455
+ }
1456
+
1457
+ function onInput(event: InputEvent): void {
1458
+ const combobox = findAncestor(event, TAGNAME);
1459
+ const state = states.map.get(combobox!);
1460
+
1461
+ if (combobox != null && state != null && event.target === state.filter.element) {
1462
+ onFilter(event.inputType, state);
1463
+ }
1464
+ }
1465
+
1466
+ function onKeydown(event: KeyboardEvent): void {
1467
+ const combobox = findAncestor(event, TAGNAME);
1468
+ const state = states.map.get(combobox!);
887
1469
 
888
1470
  if (combobox == null || state == null) {
889
1471
  return;
890
1472
  }
891
1473
 
892
- const option = findAncestor(event, SELECTOR_OPTION);
1474
+ const item = findAncestor(event, SELECTOR_SELECTION_ITEM);
1475
+
1476
+ if (item instanceof HTMLElement) {
1477
+ onKeydownSelection(event, state, item);
1478
+
1479
+ return;
1480
+ }
1481
+
1482
+ onKeydownFilter(event, state);
1483
+ }
1484
+
1485
+ function onKeydownFilter(event: KeyboardEvent, state: OuiComboboxState): void {
1486
+ const ignore = state.disabled || state.readonly;
1487
+ const isSelect = state.type === 'select';
1488
+
1489
+ let preventDefault = ignore;
1490
+
1491
+ switch (true) {
1492
+ case event.key === ' ': {
1493
+ if (!state.disabled) {
1494
+ state.floatable.show();
1495
+ }
1496
+
1497
+ break;
1498
+ }
1499
+
1500
+ case event.key === 'Enter': {
1501
+ preventDefault = state.floatable.open;
1502
+
1503
+ if (preventDefault) {
1504
+ onEnter(state);
1505
+ } else if (!ignore && state.type === 'select') {
1506
+ state.floatable.show();
1507
+ } else if (!ignore) {
1508
+ state.internals.form?.requestSubmit();
1509
+ }
1510
+
1511
+ break;
1512
+ }
1513
+
1514
+ case event.key === 'Escape': {
1515
+ preventDefault = false;
1516
+
1517
+ onEscape(state);
1518
+
1519
+ break;
1520
+ }
1521
+
1522
+ case !isSelect && KEYS_SELECTION.has(event.key): {
1523
+ if (!ignore && hasTextSelection(state.anchor.element)) {
1524
+ onSelection(state);
1525
+ }
1526
+
1527
+ break;
1528
+ }
1529
+
1530
+ case KEYS_NAVIGATION.has(event.key): {
1531
+ if (!state.disabled) {
1532
+ onNavigation(event, state);
1533
+ }
1534
+
1535
+ break;
1536
+ }
1537
+
1538
+ default: {
1539
+ preventDefault = false;
1540
+
1541
+ if (event.key === 'Tab') {
1542
+ onTab(state);
1543
+ }
1544
+
1545
+ break;
1546
+ }
1547
+ }
1548
+
1549
+ if (preventDefault) {
1550
+ event.preventDefault();
1551
+ event.stopPropagation();
1552
+ }
1553
+ }
1554
+
1555
+ function onKeydownSelection(
1556
+ event: KeyboardEvent,
1557
+ state: OuiComboboxState,
1558
+ item: HTMLElement,
1559
+ ): void {
1560
+ if (KEYS_ACTIVATION.has(event.key)) {
1561
+ event.preventDefault();
1562
+
1563
+ onRemoveSelection(state, item);
1564
+
1565
+ return;
1566
+ }
1567
+
1568
+ if (state.disabled || state.selection == null) {
1569
+ return;
1570
+ }
1571
+
1572
+ const {length} = state.selection.items;
1573
+
1574
+ const index = state.selection.items.findIndex(selection => selection.button === item);
1575
+
1576
+ let next: number | undefined;
1577
+
1578
+ switch (event.key) {
1579
+ case 'ArrowDown':
1580
+ state.anchor.element.focus();
1581
+ state.anchor.element.click();
1582
+ return;
893
1583
 
894
- if (option instanceof HTMLElement) {
895
- setNext('set', combobox, state, getOptions(state)[0], option);
896
- }
1584
+ case 'ArrowRight':
1585
+ case 'ArrowLeft':
1586
+ next = clamp(index + (event.key === 'ArrowRight' ? 1 : -1), 0, length - 1, true);
1587
+ break;
897
1588
 
898
- state.anchor.focus();
899
- }
1589
+ case 'End':
1590
+ next = length - 1;
1591
+ break;
900
1592
 
901
- function onEnter(combobox: OuiComboboxElement, state: OuiComboboxState): void {
902
- if (hasTextSelection(state.anchor)) {
903
- onSelection(combobox, state);
1593
+ case 'Home':
1594
+ next = 0;
1595
+ break;
904
1596
 
905
- return;
1597
+ default:
1598
+ break;
906
1599
  }
907
1600
 
908
- if (!state.floatable.open) {
909
- state.floatable.show();
910
-
1601
+ if (next == null) {
911
1602
  return;
912
1603
  }
913
1604
 
914
- setNext('set', combobox, state, getOptions(state)[0], state.active);
915
- }
1605
+ event.preventDefault();
916
1606
 
917
- function onEscape(state: OuiComboboxState): void {
918
- state.floatable.hide();
919
- }
1607
+ for (let index = 0; index < length; index += 1) {
1608
+ const {button} = state.selection.items[index];
1609
+ const selected = index === next;
920
1610
 
921
- function onKeydown(event: KeyboardEvent): void {
922
- onAnchor(event, onAnchorKeydown);
923
- }
1611
+ button.tabIndex = selected ? 0 : -1;
924
1612
 
925
- function onKeyup(event: KeyboardEvent): void {
926
- onAnchor(event, onAnchorKeyup);
1613
+ if (selected) {
1614
+ button.focus();
1615
+ }
1616
+ }
927
1617
  }
928
1618
 
929
- function onNavigation(
930
- event: KeyboardEvent,
931
- combobox: OuiComboboxElement,
932
- state: OuiComboboxState,
933
- ): void {
1619
+ function onNavigation(event: KeyboardEvent, state: OuiComboboxState): void {
934
1620
  let skip = false;
935
1621
 
936
1622
  if (!state.floatable.open) {
@@ -939,7 +1625,7 @@ function onNavigation(
939
1625
  state.floatable.show();
940
1626
  }
941
1627
 
942
- const [all, visible] = getOptions(state);
1628
+ const visible = getVisible(state);
943
1629
 
944
1630
  if (visible.length === 0) {
945
1631
  return;
@@ -982,376 +1668,571 @@ function onNavigation(
982
1668
  }
983
1669
  }
984
1670
 
985
- setNext(state.autocomplete.none ? 'none' : 'navigation', combobox, state, all, next);
986
- }
987
-
988
- function onRemove(combobox: OuiComboboxElement, state: OuiComboboxState): void {
989
- const [all] = getOptions(state);
1671
+ let preview: OuiComboboxPreview;
990
1672
 
991
- const next = filterOptions(state, all);
1673
+ if (state.type === 'autocomplete' && (state.autocomplete.both || state.autocomplete.inline)) {
1674
+ preview = 'set';
1675
+ } else {
1676
+ preview = 'none';
1677
+ }
992
1678
 
993
- setNext('remove', combobox, state, all, next);
1679
+ updatePreview(preview, state, next ?? state.active ?? visible[0]);
994
1680
  }
995
1681
 
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();
1682
+ function onRemoveSelection(state: OuiComboboxState, item: HTMLElement): void {
1683
+ if (state.disabled || state.readonly) {
1684
+ return;
1685
+ }
1004
1686
 
1005
- const next =
1006
- all.find(option => option.textContent?.toLocaleLowerCase().startsWith(key) === true) ??
1007
- all.find(option => option.textContent?.toLocaleLowerCase().includes(key) === true);
1687
+ const option = state.options.selected.find(
1688
+ option => option.id === getData(item, 'option', false),
1689
+ );
1008
1690
 
1009
- if (next != null) {
1010
- setNext('navigation', combobox, state, all, next);
1691
+ if (option != null) {
1692
+ toggleOption(state, option);
1011
1693
  }
1012
1694
  }
1013
1695
 
1014
- function onSelection(combobox: OuiComboboxElement, state: OuiComboboxState): void {
1015
- state.filter = state.anchor instanceof HTMLInputElement ? state.anchor.value : '';
1696
+ function onSelection(state: OuiComboboxState): void {
1697
+ state.filter.value =
1698
+ state.anchor.element === state.filter.element ? state.filter.element.value : '';
1016
1699
 
1017
1700
  filterOptions(state);
1018
1701
 
1019
- setNext('set', combobox, state, getOptions(state)[0], state.active);
1702
+ toggleOption(state, state.active);
1020
1703
  }
1021
1704
 
1022
- function onTab(combobox: OuiComboboxElement, state: OuiComboboxState): void {
1023
- if (combobox !== document.activeElement || !combobox.contains(document.activeElement)) {
1024
- state.floatable.hide();
1025
- }
1705
+ function onTab(state: OuiComboboxState): void {
1706
+ setTimeout(() => {
1707
+ if (state.host === document.activeElement) {
1708
+ return;
1709
+ }
1710
+
1711
+ if (
1712
+ state.selection?.container.contains(document.activeElement) ||
1713
+ !state.host.contains(document.activeElement)
1714
+ ) {
1715
+ state.floatable.hide();
1716
+ }
1717
+ });
1026
1718
  }
1027
1719
 
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
- ) {
1034
- state.floatable.show();
1720
+ function setDisabled(state: OuiComboboxState, value: boolean): void {
1721
+ state.filter.element.readOnly = state.readonly || value;
1722
+
1723
+ setProperties('disabled', state, value);
1724
+ }
1725
+
1726
+ function setFilter(state: OuiComboboxState, value: unknown): void {
1727
+ if (typeof value !== 'string') {
1728
+ return;
1035
1729
  }
1036
1730
 
1037
- const options = getOptions(state)[0];
1731
+ const type: InputEvent['inputType'] =
1732
+ value.length < state.filter.element.value.length ? 'deleteContentBackward' : 'insertText';
1038
1733
 
1039
- const next = state.autocomplete.none ? (state.active ?? options[0]) : filterOptions(state);
1734
+ state.filter.element.value = value;
1040
1735
 
1041
- setNext('typed', combobox, state, options, next);
1736
+ onFilter(type, state);
1042
1737
  }
1043
1738
 
1044
- function setDisabled(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1045
- state.disabled = value;
1739
+ function setLimit(type: 'max' | 'min', state: OuiComboboxState, value?: unknown): void {
1740
+ let actual = value;
1046
1741
 
1047
- setAria(state.anchor, 'disabled', value);
1048
- setProperty(combobox, 'disabled', value);
1049
-
1050
- if (state.anchor instanceof HTMLInputElement) {
1051
- state.anchor.readOnly = value;
1742
+ if (typeof actual === 'string') {
1743
+ actual = Number.parseInt(actual, 10);
1052
1744
  }
1053
- }
1054
1745
 
1055
- function setLimit(
1056
- type: 'max' | 'min',
1057
- combobox: OuiComboboxElement,
1058
- state: OuiComboboxState,
1059
- value?: number,
1060
- ): void {
1061
1746
  const updateLimit =
1062
- typeof value === 'number' && !Number.isNaN(value) ? value !== state[type] : value == null;
1747
+ typeof actual === 'number' && !Number.isNaN(actual) && actual >= 0
1748
+ ? actual !== state[type]
1749
+ : actual == null;
1063
1750
 
1064
1751
  if (updateLimit) {
1065
- state[type] = value;
1752
+ state[type] = actual as number | undefined;
1066
1753
 
1067
- setAttribute(combobox, type, value);
1754
+ setAttribute(state.host, type, actual);
1068
1755
 
1069
1756
  updateValidity(state);
1070
1757
  }
1071
1758
  }
1072
1759
 
1073
- function setNext(
1074
- update: OuiComboboxUpdate,
1075
- combobox: OuiComboboxElement,
1760
+ /**
1761
+ * Set combobox localizations for a specific language
1762
+ *
1763
+ * @param language Language code
1764
+ * @param localizations Localizations for language
1765
+ */
1766
+ export function setLocalizations(
1767
+ language: string,
1768
+ localizations: Partial<OuiComboboxLocalizations>,
1769
+ ): void;
1770
+
1771
+ /**
1772
+ * Set combobox localizations for one or more languages
1773
+ *
1774
+ * @param localizations Localizations for one or more languages
1775
+ */
1776
+ export function setLocalizations(
1777
+ localizations: Record<string, Partial<OuiComboboxLocalizations>>,
1778
+ ): void;
1779
+
1780
+ export function setLocalizations(
1781
+ first: string | Record<string, Partial<OuiComboboxLocalizations>>,
1782
+ second?: Partial<OuiComboboxLocalizations>,
1783
+ ): void {
1784
+ updateLocalizations(
1785
+ typeof first === 'string'
1786
+ ? {
1787
+ [first]: second!,
1788
+ }
1789
+ : first,
1790
+ );
1791
+ }
1792
+
1793
+ function setProperties(
1794
+ key: 'disabled' | 'readonly' | 'required',
1076
1795
  state: OuiComboboxState,
1077
- all: HTMLElement[],
1078
- next?: HTMLElement,
1796
+ value: boolean,
1079
1797
  ): void {
1080
- for (const option of all) {
1081
- setAttribute(option, ATTRIBUTE_OPTION_ACTIVE, option === next ? '' : undefined);
1082
- }
1798
+ state[key] = value;
1799
+
1800
+ const elements = [state.host, state.anchor.element, state.filter.element];
1801
+ const {length} = elements;
1802
+
1803
+ for (let index = 0; index < length; index += 1) {
1804
+ const element = elements[index];
1083
1805
 
1084
- state.active = next;
1806
+ setAria(
1807
+ element,
1808
+ key,
1809
+ key !== 'readonly' || !(element instanceof HTMLButtonElement) ? value : undefined,
1810
+ );
1811
+ }
1812
+ }
1085
1813
 
1086
- setAria(state.anchor, 'activedescendant', next?.id);
1814
+ function setReadonly(state: OuiComboboxState, value: boolean): void {
1815
+ state.filter.element.readOnly = state.disabled || value;
1087
1816
 
1088
- next?.scrollIntoView(scrollOptions);
1817
+ setProperties('readonly', state, value);
1818
+ }
1089
1819
 
1090
- setValue(update, combobox, state, next);
1820
+ function setRequired(state: OuiComboboxState, value: boolean): void {
1821
+ setProperties('required', state, value);
1822
+ updateValidity(state);
1091
1823
  }
1092
1824
 
1093
- function setReadonly(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1094
- state.readonly = value;
1825
+ function setValue(state: OuiComboboxState, value: unknown, emit?: boolean): void {
1826
+ if (state.autocomplete.none) {
1827
+ state.filter.element.value = getString(value);
1828
+
1829
+ onFilter('insertText', state, true, emit);
1830
+
1831
+ return;
1832
+ }
1095
1833
 
1096
- setAria(state.anchor, 'readonly', value);
1097
- setProperty(combobox, 'readonly', value);
1834
+ const option = filterOption(state, value);
1098
1835
 
1099
- if (state.anchor instanceof HTMLInputElement) {
1100
- state.anchor.readOnly = value;
1836
+ if (option != null) {
1837
+ setValues(state, [value], emit);
1101
1838
  }
1102
1839
  }
1103
1840
 
1104
- function setRequired(combobox: OuiComboboxElement, state: OuiComboboxState, value: boolean): void {
1105
- state.required = value;
1841
+ function setValues(state: OuiComboboxState, value: unknown, emit?: boolean): void {
1842
+ if (!Array.isArray(value)) {
1843
+ return;
1844
+ }
1106
1845
 
1107
- setAria(state.anchor, 'required', value);
1108
- setProperty(combobox, 'required', value);
1846
+ const values = (state.multiple ? value : value.slice(0, 1)).map(value =>
1847
+ getString(value).toLocaleLowerCase(),
1848
+ );
1109
1849
 
1110
- updateValidity(state);
1111
- }
1850
+ if (values.length === 0) {
1851
+ values.push('');
1852
+ }
1853
+
1854
+ if (state.autocomplete.none) {
1855
+ setValue(state, values[0], emit);
1112
1856
 
1113
- function setValue(
1114
- update: OuiComboboxUpdate,
1115
- combobox: OuiComboboxElement,
1116
- state: OuiComboboxState,
1117
- next?: HTMLElement,
1118
- ): void {
1119
- if (state.disabled || state.readonly || (state.type === 'select' && update !== 'set')) {
1120
1857
  return;
1121
1858
  }
1122
1859
 
1123
- let added: boolean | undefined;
1124
- let shouldUpdate: boolean;
1125
- let values: string[];
1860
+ const {length} = state.options.all;
1126
1861
 
1127
- if (update === 'set' || !(state.anchor instanceof HTMLInputElement)) {
1128
- added = updateSelected(update, state, next);
1862
+ const selected: HTMLElement[] = [];
1129
1863
 
1130
- [values, shouldUpdate] = getFormValues(state, added);
1131
- } else {
1132
- const [all] = getOptions(state);
1864
+ for (let index = 0; index < length; index += 1) {
1865
+ const option = state.options.all[index];
1133
1866
 
1134
- for (const option of all) {
1135
- setAria(option, 'selected', false);
1867
+ if (values.includes(getData(option, 'value', false)?.toLocaleLowerCase() ?? '')) {
1868
+ selected.push(option);
1136
1869
  }
1870
+ }
1871
+
1872
+ applySelection({
1873
+ emit,
1874
+ selected,
1875
+ state,
1876
+ added: true,
1877
+ option: selected[0] ?? state.options.all[0],
1878
+ values: state.autocomplete.none ? values : undefined,
1879
+ });
1880
+ }
1137
1881
 
1138
- const input = state.anchor.value;
1882
+ function toggleOption(state: OuiComboboxState, option?: HTMLElement): void {
1883
+ if (option == null || (!state.multiple && state.options.selected.includes(option))) {
1884
+ state.floatable.hide();
1139
1885
 
1140
- const content = next?.textContent ?? '';
1141
- const value = next == null ? '' : (getAttribute(next, 'value') ?? '');
1886
+ return;
1887
+ }
1142
1888
 
1143
- let selected: HTMLElement | undefined;
1889
+ const selected = state.options.selected.slice();
1144
1890
 
1145
- if (content.toLocaleLowerCase() === input.toLocaleLowerCase()) {
1146
- values = [value];
1891
+ let added = true;
1147
1892
 
1148
- if (next != null) {
1149
- selected = next;
1893
+ if (state.multiple) {
1894
+ const index = selected.indexOf(option);
1150
1895
 
1151
- setAria(next, 'selected', true);
1152
- }
1896
+ if (index === -1) {
1897
+ selected.push(option);
1153
1898
  } else {
1154
- values = [state.autocomplete.none ? input : ''];
1899
+ added = false;
1900
+
1901
+ selected.splice(index, 1);
1155
1902
  }
1903
+ } else {
1904
+ selected.splice(0, selected.length, option);
1905
+ }
1156
1906
 
1157
- state.selected = selected == null ? [] : [selected];
1907
+ applySelection({
1908
+ added,
1909
+ option,
1910
+ selected,
1911
+ state,
1912
+ values: state.autocomplete.none && option != null ? [option.textContent] : undefined,
1913
+ });
1158
1914
 
1159
- shouldUpdate = state.values[0] !== values[0];
1915
+ if (!state.multiple) {
1916
+ state.floatable.hide();
1160
1917
  }
1918
+ }
1161
1919
 
1162
- if (shouldUpdate) {
1163
- updateForm(combobox, state, values);
1920
+ function updateAnchor(state: OuiComboboxState): void {
1921
+ if (!state.multiple) {
1922
+ return;
1164
1923
  }
1165
1924
 
1166
- if (next == null) {
1925
+ const localization = getLocalization('selected', state);
1926
+
1927
+ setAria(
1928
+ state.anchor.element,
1929
+ 'label',
1930
+ typeof localization === 'function'
1931
+ ? localization(state.options.selected.length)
1932
+ : localization.replace('{amount}', state.options.selected.length.toString()),
1933
+ );
1934
+ }
1935
+
1936
+ function updateAutocomplete(
1937
+ preview: OuiComboboxPreview,
1938
+ state: OuiComboboxState,
1939
+ active?: HTMLElement,
1940
+ ): void {
1941
+ if (state.multiple) {
1167
1942
  return;
1168
1943
  }
1169
1944
 
1170
- if (state.type === 'select') {
1171
- updateSelect(update, state, next, added);
1172
- } else if (update !== 'remove') {
1173
- updateAutoComplete(update, state, next);
1945
+ const content = active?.textContent ?? state.filter.element.value;
1946
+
1947
+ if (
1948
+ preview === 'typed' &&
1949
+ !content.toLocaleLowerCase().startsWith(state.filter.element.value.toLocaleLowerCase())
1950
+ ) {
1951
+ return;
1174
1952
  }
1175
1953
 
1176
- if (update === 'set' && !state.multiple) {
1177
- state.floatable.hide();
1954
+ if (state.anchor.element instanceof HTMLInputElement) {
1955
+ const contentLength = content.length;
1956
+ const filterLength = state.filter.element.value.length;
1957
+
1958
+ if (preview === 'set' || preview === 'typed') {
1959
+ state.anchor.element.value = content;
1960
+
1961
+ state.anchor.element.setSelectionRange(
1962
+ preview === 'typed' ? filterLength : contentLength,
1963
+ contentLength,
1964
+ );
1965
+ }
1966
+ } else {
1967
+ state.anchor.element.textContent = content;
1178
1968
  }
1969
+ }
1179
1970
 
1180
- setTimeout(() => {
1181
- updateWidth(state);
1182
- });
1971
+ function updateForm(state: OuiComboboxState, values: string[], emit?: boolean): void {
1972
+ let value: string | FormData;
1973
+
1974
+ if (state.multiple) {
1975
+ const {length} = values;
1976
+
1977
+ value = new FormData();
1978
+
1979
+ for (let index = 0; index < length; index += 1) {
1980
+ (value as FormData).append(state.name, values[index]);
1981
+ }
1982
+ } else {
1983
+ value = values[0];
1984
+ }
1985
+
1986
+ state.values.current = values;
1987
+
1988
+ state.internals.setFormValue(value, JSON.stringify(values));
1989
+
1990
+ updateAnchor(state);
1991
+ updateValidity(state);
1992
+
1993
+ if (emit ?? true) {
1994
+ dispatch(state.host, 'change');
1995
+ }
1183
1996
  }
1184
1997
 
1185
- function setValues(combobox: OuiComboboxElement, state: OuiComboboxState, value: unknown): void {
1186
- if (!Array.isArray(value)) {
1998
+ function updateLocalizations(
1999
+ values: Record<string, Partial<Record<string, string | GenericCallback>>>,
2000
+ destination?: Record<string, Partial<OuiComboboxLocalizations>>,
2001
+ ): void {
2002
+ if (typeof values !== 'object' || values == null) {
1187
2003
  return;
1188
2004
  }
1189
2005
 
1190
- const strings = (state.multiple ? value : [value]).map(value =>
1191
- getString(value).toLocaleLowerCase(),
1192
- );
2006
+ const localizations = destination ?? LOCALIZATIONS.globals;
1193
2007
 
1194
- const [all] = getOptions(state);
1195
-
1196
- const selected: HTMLElement[] = [];
1197
- const values: string[] = [];
2008
+ const languages = Object.keys(values);
2009
+ const languagesLength = languages.length;
1198
2010
 
1199
- let next: HTMLElement | undefined;
2011
+ let updated = false;
1200
2012
 
1201
- for (const option of all) {
1202
- const value = getAttribute(option, 'value');
2013
+ for (let languagesIndex = 0; languagesIndex < languagesLength; languagesIndex += 1) {
2014
+ const language = languages[languagesIndex];
2015
+ const languageValue = values[language];
1203
2016
 
1204
- if (value == null || !strings.includes(value.toLocaleLowerCase())) {
2017
+ if (typeof languageValue !== 'object' || languageValue == null) {
1205
2018
  continue;
1206
2019
  }
1207
2020
 
1208
- selected.push(option);
1209
- values.push(value);
2021
+ const normalizedLanguage = language.toLowerCase();
1210
2022
 
1211
- next ??= option;
1212
- }
2023
+ localizations[normalizedLanguage] ??= {};
2024
+
2025
+ const keys = Object.keys(languageValue);
2026
+ const keysLength = keys.length;
2027
+
2028
+ for (let keysIndex = 0; keysIndex < keysLength; keysIndex += 1) {
2029
+ const key = keys[keysIndex];
2030
+ const keyValue = languageValue[key];
1213
2031
 
1214
- for (const option of state.selected) {
1215
- setAria(option, 'selected', false);
2032
+ const normalizedKey = key.toLowerCase() as keyof OuiComboboxLocalizations;
1216
2033
 
1217
- state.anchor.querySelector(`[${ATTRIBUTE_SELECTION_ITEM}][option="${option.id}"]`)?.remove();
2034
+ if (
2035
+ LOCALIZATIONS.keys.has(normalizedKey) &&
2036
+ (typeof keyValue === 'string' || typeof keyValue === 'function')
2037
+ ) {
2038
+ updated = true;
2039
+
2040
+ localizations[normalizedLanguage][normalizedKey] = keyValue as never;
2041
+ }
2042
+ }
1218
2043
  }
1219
2044
 
1220
- for (const option of selected) {
1221
- setAria(option, 'selected', true);
2045
+ if (!updated) {
2046
+ return;
2047
+ }
1222
2048
 
1223
- state.anchor.querySelector(`[${ATTRIBUTE_SELECTION}]`)?.append(getSelectionItem(option));
2049
+ for (const state of states.set) {
2050
+ updateAnchor(state);
2051
+ updateMessage(state, getVisible(state).length);
2052
+ updateSelectionItems(state);
1224
2053
  }
2054
+ }
2055
+
2056
+ function updateMessage(state: OuiComboboxState, visible: number): void {
2057
+ setAttribute(state.message, ATTRIBUTE_HIDDEN, visible > 0 ? '' : undefined);
2058
+
2059
+ if (visible === 0) {
2060
+ const localization = getLocalization('empty', state);
1225
2061
 
1226
- state.selected = selected.slice();
2062
+ state.message.textContent = typeof localization === 'function' ? localization() : localization;
2063
+ } else {
2064
+ const localization = getLocalization('results', state);
1227
2065
 
1228
- setNext('navigation', combobox, state, all, next);
2066
+ state.message.textContent =
2067
+ typeof localization === 'function'
2068
+ ? localization(visible)
2069
+ : localization.replace('{amount}', visible.toString());
2070
+ }
1229
2071
 
1230
- updateForm(combobox, state, values);
2072
+ state.message.hidden = false;
1231
2073
  }
1232
2074
 
1233
- function updateAutoComplete(
1234
- update: OuiComboboxUpdate,
2075
+ function updatePreview(
2076
+ preview: OuiComboboxPreview,
1235
2077
  state: OuiComboboxState,
1236
- next: HTMLElement,
2078
+ active?: HTMLElement,
1237
2079
  ): void {
1238
- if (!(update === 'set' || state.autocomplete.both || state.autocomplete.inline)) {
1239
- return;
1240
- }
2080
+ const {length} = state.options.all;
1241
2081
 
1242
- const content = next.textContent ?? '';
2082
+ for (let index = 0; index < length; index += 1) {
2083
+ const option = state.options.all[index];
1243
2084
 
1244
- if (
1245
- update === 'typed' &&
1246
- !content.toLocaleLowerCase().startsWith(state.filter.toLocaleLowerCase())
1247
- ) {
1248
- return;
2085
+ setAttribute(option, ATTRIBUTE_OPTION_ACTIVE, option === active ? '' : undefined);
1249
2086
  }
1250
2087
 
1251
- const {length} = content;
2088
+ state.active = active;
1252
2089
 
1253
- if (state.anchor instanceof HTMLInputElement) {
1254
- state.anchor.value = content;
2090
+ setAria(state.filter.element, 'activedescendant', state.floatable.open ? active?.id : undefined);
1255
2091
 
1256
- state.anchor.setSelectionRange(update === 'typed' ? state.filter.length : length, length);
1257
- } else {
1258
- state.anchor.textContent = content;
2092
+ if (state.floatable.open) {
2093
+ active?.scrollIntoView(scrollOptions);
2094
+ }
2095
+
2096
+ if (preview !== 'none') {
2097
+ updateAutocomplete(preview, state, active);
1259
2098
  }
1260
2099
  }
1261
2100
 
1262
- function updateForm(combobox: OuiComboboxElement, state: OuiComboboxState, values: string[]): void {
1263
- const formValues = values.length === 0 ? [''] : values;
2101
+ function updateSelection(
2102
+ state: OuiComboboxState,
2103
+ active: HTMLElement | undefined,
2104
+ added: boolean,
2105
+ selected?: HTMLElement[],
2106
+ ): void {
2107
+ if (state.selection == null) {
2108
+ return;
2109
+ }
1264
2110
 
1265
- const formData = new FormData();
2111
+ let options = active == null ? [] : [active];
1266
2112
 
1267
- for (const value of formValues) {
1268
- formData.append(state.name, value);
1269
- }
2113
+ if (selected != null) {
2114
+ options = selected;
1270
2115
 
1271
- state.values = formValues;
2116
+ const {length} = state.selection.items;
1272
2117
 
1273
- state.internals.setFormValue(formData);
2118
+ for (let index = 0; index < length; index += 1) {
2119
+ state.selection.items[index].element.remove();
2120
+ }
1274
2121
 
1275
- updateValidity(state);
2122
+ state.selection.items.splice(0);
2123
+ }
1276
2124
 
1277
- dispatch(combobox, 'change');
1278
- }
2125
+ if (added) {
2126
+ createSelectionItems({
2127
+ options,
2128
+ state,
2129
+ container: state.selection.container,
2130
+ items: state.selection.items,
2131
+ });
1279
2132
 
1280
- function updateSelect(
1281
- update: OuiComboboxUpdate,
1282
- state: OuiComboboxState,
1283
- next: HTMLElement,
1284
- added?: boolean,
1285
- ): void {
1286
- if (update !== 'set') {
1287
2133
  return;
1288
2134
  }
1289
2135
 
1290
- if (!state.multiple) {
1291
- state.anchor.textContent = next.textContent ?? '';
2136
+ let current = state.selection.items.findIndex(
2137
+ item => getData(item.button, 'option', false) === active?.id,
2138
+ );
1292
2139
 
2140
+ if (current === -1) {
1293
2141
  return;
1294
2142
  }
1295
2143
 
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();
2144
+ const item = state.selection.items[current];
2145
+
2146
+ item.element.remove();
2147
+
2148
+ state.selection.items.splice(current, 1);
2149
+
2150
+ if (state.selection.items.length === 0) {
2151
+ state.anchor.element.focus();
2152
+
2153
+ return;
1300
2154
  }
1301
- }
1302
2155
 
1303
- function updateSelected(
1304
- update: OuiComboboxUpdate,
1305
- state: OuiComboboxState,
1306
- next?: HTMLElement,
1307
- ): boolean | undefined {
1308
- const [all] = getOptions(state);
2156
+ const {length} = state.selection.items;
1309
2157
 
1310
- for (const option of all) {
1311
- setAria(option, 'selected', getSelected(update, state, option, next));
2158
+ if (current < 0) {
2159
+ current = 0;
2160
+ } else if (current >= length) {
2161
+ current = length - 1;
1312
2162
  }
1313
2163
 
1314
- if (next == null) {
1315
- return;
2164
+ for (let index = 0; index < length; index += 1) {
2165
+ const {button} = state.selection.items[index];
2166
+
2167
+ button.tabIndex = index === current ? 0 : -1;
1316
2168
  }
1317
2169
 
1318
- if (state.type !== 'select') {
1319
- state.selected = [next];
2170
+ state.selection.items[current].button.focus();
2171
+ }
1320
2172
 
2173
+ function updateSelectionItems(state: OuiComboboxState): void {
2174
+ if (state.selection == null) {
1321
2175
  return;
1322
2176
  }
1323
2177
 
1324
- if (update !== 'set') {
2178
+ const localization = getLocalization('remove', state);
2179
+
2180
+ const {length} = state.selection.items;
2181
+
2182
+ for (let index = 0; index < length; index += 1) {
2183
+ const item = state.selection.items[index];
2184
+
2185
+ const option = state.options.selected.find(
2186
+ option => option.id === getData(item.button, 'option', false),
2187
+ );
2188
+
2189
+ if (option == null) {
2190
+ continue;
2191
+ }
2192
+
2193
+ setAria(
2194
+ item.button,
2195
+ 'label',
2196
+ typeof localization === 'function'
2197
+ ? localization(option?.textContent ?? '', getData(option, 'value', false) ?? '')
2198
+ : localization.replace('{label}', option?.textContent ?? ''),
2199
+ );
2200
+ }
2201
+ }
2202
+
2203
+ function updateTabindices(state: OuiComboboxState, active?: HTMLElement): void {
2204
+ if (active == null || state.selection == null) {
1325
2205
  return;
1326
2206
  }
1327
2207
 
1328
- if (!state.multiple) {
1329
- state.selected = [next];
2208
+ const next = state.selection.items.find(
2209
+ item => getData(item.button, 'option', false) === active.id,
2210
+ );
1330
2211
 
2212
+ if (next == null) {
1331
2213
  return;
1332
2214
  }
1333
2215
 
1334
- const index = state.selected.indexOf(next);
1335
- const added = index === -1;
2216
+ const {length} = state.selection.items;
1336
2217
 
1337
- if (added) {
1338
- state.selected.push(next);
1339
- } else {
1340
- state.selected.splice(index, 1);
2218
+ for (let index = 0; index < length; index += 1) {
2219
+ const item = state.selection.items[index];
2220
+
2221
+ item.button.tabIndex = item === next ? 0 : -1;
1341
2222
  }
1342
2223
 
1343
- return added;
2224
+ next.button.focus();
1344
2225
  }
1345
2226
 
1346
2227
  function updateValidity(state: OuiComboboxState): void {
1347
2228
  if (state.autocomplete.none) {
1348
- const valueMissing = state.required && state.values[0].length === 0;
2229
+ const valueMissing = state.required && state.filter.element.value.length === 0;
2230
+
2231
+ const localization = getLocalization('input', state);
1349
2232
 
1350
2233
  state.internals.setValidity(
1351
- {
1352
- valueMissing,
1353
- },
1354
- INVALID_INPUT,
2234
+ {valueMissing},
2235
+ typeof localization === 'function' ? localization() : localization,
1355
2236
  );
1356
2237
 
1357
2238
  state.internals.checkValidity();
@@ -1359,41 +2240,66 @@ function updateValidity(state: OuiComboboxState): void {
1359
2240
  return;
1360
2241
  }
1361
2242
 
1362
- const rangeOverflow =
1363
- state.multiple && typeof state.max === 'number' && state.selected.length > state.max;
2243
+ let error: 'badInput' | 'rangeOverflow' | 'rangeUnderflow' | undefined;
2244
+ let message: string | undefined;
2245
+
2246
+ switch (true) {
2247
+ case state.required && state.options.selected.length === 0: {
2248
+ error = 'badInput';
2249
+
2250
+ const localization = getLocalization('input', state);
2251
+
2252
+ message = typeof localization === 'function' ? localization() : localization;
2253
+
2254
+ break;
2255
+ }
2256
+
2257
+ case state.multiple &&
2258
+ typeof state.max === 'number' &&
2259
+ state.options.selected.length > state.max: {
2260
+ error = 'rangeOverflow';
2261
+
2262
+ const localization = getLocalization('max', state);
2263
+
2264
+ message =
2265
+ typeof localization === 'function'
2266
+ ? localization(state.max)
2267
+ : localization.replace('{max}', state.max.toString());
1364
2268
 
1365
- const rangeUnderflow =
1366
- state.multiple && typeof state.min === 'number' && state.selected.length < state.min;
2269
+ break;
2270
+ }
2271
+
2272
+ case state.multiple &&
2273
+ typeof state.min === 'number' &&
2274
+ state.options.selected.length < state.min: {
2275
+ error = 'rangeUnderflow';
2276
+
2277
+ const localization = getLocalization('min', state);
2278
+
2279
+ message =
2280
+ typeof localization === 'function'
2281
+ ? localization(state.min)
2282
+ : localization.replace('{min}', state.min.toString());
2283
+
2284
+ break;
2285
+ }
1367
2286
 
1368
- const badInput = state.required && state.selected.length === 0;
2287
+ default:
2288
+ break;
2289
+ }
1369
2290
 
1370
2291
  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
- ),
2292
+ error == null
2293
+ ? {}
2294
+ : {
2295
+ [error]: true,
2296
+ },
2297
+ message,
1388
2298
  );
1389
2299
 
1390
2300
  state.internals.checkValidity();
1391
2301
  }
1392
2302
 
1393
- function updateWidth(state: OuiComboboxState): void {
1394
- setStyle(state.content, PROPERTY_STYLE, `${state.anchor.getBoundingClientRect().width}px`);
1395
- }
1396
-
1397
2303
  // #endregion
1398
2304
 
1399
2305
  // #region Variables
@@ -1404,10 +2310,18 @@ const ATTRIBUTE_ANCHOR = `${TAGNAME}-anchor`;
1404
2310
 
1405
2311
  const ATTRIBUTE_CONTENT = `${TAGNAME}-content`;
1406
2312
 
1407
- const ATTRIBUTE_EMPTY = `${TAGNAME}-empty`;
2313
+ const ATTRIBUTE_FILTER = `${TAGNAME}-filter`;
2314
+
2315
+ const ATTRIBUTE_FILTER_INPUT = `${ATTRIBUTE_FILTER}-input`;
2316
+
2317
+ const ATTRIBUTE_HEADER = `${TAGNAME}-header`;
2318
+
2319
+ const ATTRIBUTE_HIDDEN = `${TAGNAME}-hidden`;
1408
2320
 
1409
2321
  const ATTRIBUTE_LIST = `${TAGNAME}-list`;
1410
2322
 
2323
+ const ATTRIBUTE_MESSAGE = `${TAGNAME}-message`;
2324
+
1411
2325
  const ATTRIBUTE_OPTION = `${TAGNAME}-option`;
1412
2326
 
1413
2327
  const ATTRIBUTE_OPTION_ACTIVE = `${TAGNAME}-option-active`;
@@ -1418,45 +2332,62 @@ const ATTRIBUTE_SELECTION_ITEM = `${TAGNAME}-selection-item`;
1418
2332
 
1419
2333
  const AUTOCOMPLETES = new Set<OuiComboboxAutoCompleteType>(['both', 'inline', 'list', 'none']);
1420
2334
 
1421
- const EXPRESSION_TYPED = /\p{L}|\s/u;
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
-
1431
- const KEYS_ABSOLUTE = new Set(['End', 'Home']);
2335
+ const KEYS_ACTIVATION = new Set([' ', 'Backspace', 'Delete', 'Enter']);
1432
2336
 
1433
2337
  const KEYS_NAVIGATION = new Set(['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp']);
1434
2338
 
1435
- const KEYS_REMOVE = new Set(['Backspace', 'Delete']);
1436
-
1437
2339
  const KEYS_SELECTION = new Set(['ArrowRight', 'End']);
1438
2340
 
2341
+ const LOCALIZATIONS: {
2342
+ defaults: OuiComboboxLocalizations;
2343
+ globals: Record<string, Partial<OuiComboboxLocalizations>>;
2344
+ keys: Set<keyof OuiComboboxLocalizations>;
2345
+ } = {
2346
+ defaults: {
2347
+ empty: 'No results found',
2348
+ input: 'Please enter a value',
2349
+ invalid: 'Please select an option from the list',
2350
+ max: max => `Please select fewer options (maximum is ${max})`,
2351
+ min: min => `Please select more options (minimum is ${min})`,
2352
+ remove: label => `Remove ${label}`,
2353
+ results: amount => `${amount} results available`,
2354
+ selected: amount => `${amount} selected`,
2355
+ },
2356
+ globals: {},
2357
+ keys: new Set<keyof OuiComboboxLocalizations>([
2358
+ 'empty',
2359
+ 'input',
2360
+ 'invalid',
2361
+ 'max',
2362
+ 'min',
2363
+ 'remove',
2364
+ 'results',
2365
+ 'selected',
2366
+ ]),
2367
+ };
2368
+
1439
2369
  const MESSAGE_LABEL = `<${TAGNAME}> must contain a <label> element for the <select> element`;
1440
2370
 
1441
2371
  const MESSAGE_SELECT = `<${TAGNAME}> must contain a valid <select> element`;
1442
2372
 
1443
- const PROPERTY_STYLE = '--oui-combobox-width';
1444
-
1445
2373
  const SELECTOR_ANCHOR = `[${ATTRIBUTE_ANCHOR}]`;
1446
2374
 
2375
+ const SELECTOR_SELECTION_ITEM = `[${ATTRIBUTE_SELECTION_ITEM}]`;
2376
+
1447
2377
  const SELECTOR_OPTION = `[${ATTRIBUTE_OPTION}]`;
1448
2378
 
1449
2379
  const TYPES = new Set<OuiComboboxType>(['autocomplete', 'select']);
1450
2380
 
1451
- const comboboxes = new WeakMap<HTMLElement, OuiComboboxElement>();
1452
-
1453
2381
  const scrollOptions: ScrollIntoViewOptions = {
1454
- behavior: 'smooth',
2382
+ behavior: 'auto',
1455
2383
  block: 'nearest',
1456
2384
  inline: 'nearest',
1457
2385
  };
1458
2386
 
1459
- const states = new WeakMap<HTMLElement, OuiComboboxState>();
2387
+ const states = {
2388
+ map: new WeakMap<HTMLElement, OuiComboboxState>(),
2389
+ set: new Set<OuiComboboxState>(),
2390
+ };
1460
2391
 
1461
2392
  let index = 0;
1462
2393
 
@@ -1464,13 +2395,13 @@ let index = 0;
1464
2395
 
1465
2396
  // #region Initialization
1466
2397
 
1467
- on(document, 'click', onClick);
2398
+ on(document, 'click', onClickDocument);
1468
2399
 
1469
- on(document, 'keydown', onKeydown, {
2400
+ on(document, 'input', onInput, {
1470
2401
  passive: false,
1471
2402
  });
1472
2403
 
1473
- on(document, 'keyup', onKeyup, {
2404
+ on(document, 'keydown', onKeydown, {
1474
2405
  passive: false,
1475
2406
  });
1476
2407