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