html-combobox-element 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +162 -46
  2. package/dist/cjs/combobox/Combobox.markup.d.ts +22 -1
  3. package/dist/cjs/combobox/Combobox.markup.js +156 -35
  4. package/dist/cjs/combobox/HTML.combobox.element.d.ts +32 -5
  5. package/dist/cjs/combobox/HTML.combobox.element.js +431 -98
  6. package/dist/cjs/combobox/HTML.combobox.option.element.d.ts +25 -4
  7. package/dist/cjs/combobox/HTML.combobox.option.element.js +104 -73
  8. package/dist/cjs/combobox/HTML.combobox.options.collection.d.ts +37 -0
  9. package/dist/cjs/combobox/HTML.combobox.options.collection.js +176 -0
  10. package/dist/cjs/combobox/HTML.combobox.tag.element.d.ts +7 -1
  11. package/dist/cjs/combobox/HTML.combobox.tag.element.js +36 -12
  12. package/dist/cjs/combobox/index.d.ts +1 -0
  13. package/dist/cjs/combobox/index.js +1 -0
  14. package/dist/cjs/index.d.ts +66 -22
  15. package/dist/cjs/index.js +11 -0
  16. package/dist/cjs/playwright.d.ts +48 -0
  17. package/dist/cjs/playwright.js +127 -0
  18. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  19. package/dist/esm/combobox/Combobox.markup.js +156 -35
  20. package/dist/esm/combobox/HTML.combobox.element.js +431 -98
  21. package/dist/esm/combobox/HTML.combobox.option.element.js +104 -73
  22. package/dist/esm/combobox/HTML.combobox.options.collection.js +170 -0
  23. package/dist/esm/combobox/HTML.combobox.tag.element.js +36 -12
  24. package/dist/esm/combobox/index.js +1 -0
  25. package/dist/esm/index.js +10 -0
  26. package/dist/esm/playwright.js +123 -0
  27. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  28. package/dist/types/combobox/Combobox.markup.d.ts +22 -1
  29. package/dist/types/combobox/HTML.combobox.element.d.ts +32 -5
  30. package/dist/types/combobox/HTML.combobox.option.element.d.ts +25 -4
  31. package/dist/types/combobox/HTML.combobox.options.collection.d.ts +37 -0
  32. package/dist/types/combobox/HTML.combobox.tag.element.d.ts +7 -1
  33. package/dist/types/combobox/index.d.ts +1 -0
  34. package/dist/types/index.d.ts +66 -22
  35. package/dist/types/playwright.d.ts +48 -0
  36. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  37. package/package.json +49 -5
package/README.md CHANGED
@@ -1,52 +1,182 @@
1
- # html-combobox-element
2
-
3
- The `<combo-box>` is a web component that provides dropdown/combobox functionality through `<select>` fully compatible API.
1
+ # HTMLComboboxElement
2
+ A customizable, and fully `HTMLSelectElement` compatible web component, with both **dropdown** and **combobox** functionality.
3
+ In other words – the `<select>` and `<selectedcontent>` polyfill.
4
+
5
+ ## Key features
6
+ Like a native `<select>`:
7
+ - It is seamlessly exposed to assistive technology;
8
+ - Fully participate in HTML forms;
9
+ - Is fully controllable by keyboard, including type-ahead;
10
+ - Supports the same CSS pseudo-classes: `:disabled`, `:required`, `:invalid` etc.;
11
+ - Dispatches same events: `change`, `input`, `reset` etc.;
12
+ - Supports `single` and `multiple` modes;
13
+ - Supports `<optgroup>` and has the same markup style.
14
+
15
+ And – its fully customizable. Itself, its options and chips.
4
16
 
5
17
  ## Install
6
18
  ```sh
7
19
  npm install html-combobox-element
8
20
  ```
9
21
 
22
+ ### Register – once in app entry point
10
23
  ```ts
11
- // app entry point
12
24
  import 'html-combobox-element';
13
25
  ```
14
- This registers the `<combo-box>`, `<box-option>` and `<box-tag>` custom elements with full TS support for plain DOM/vanilla-JS usage.
26
+ This registers the `<combo-box>`, `<box-option>` and `<box-tag>` custom elements with full TS support.
27
+
28
+ ### Usage
29
+ ```html
30
+ <combo-box multiple required>
31
+ <box-option value="apple" selected>
32
+ Apple
33
+ </box-option>
34
+
35
+ <optgroup label="Group">
36
+ <box-option value="banana" selected>
37
+ Banana
38
+ </box-option>
39
+ <box-option value="cherry">
40
+ Cherry
41
+ </box-option>
42
+ </optgroup>
43
+ </combo-box>
44
+ ```
15
45
 
16
- ### TypeScript + JSX
17
- Using `<combo-box>` in JSX or a Vue template needs one extra step, due to a TypeScript limitation: JSX/template type-checking only picks up custom element typings from a
18
- file in your own project - not from a package's shipped `.d.ts`.
46
+ ### Differences from `<select>`
47
+ - In `multiple` mode, the `combobox.value` lists every selected value, where `select.value` gives only the first;
48
+ - In `single`mode, the `<combo-box>` without a selected option stays empty, where a `<select>` selects its first option.
49
+
50
+ ### Events – type: when
51
+ - `change`: The user changed the selection. Once per action, only when the value changed.
52
+ - `input`: The user typed in the search field. It is the field's own `InputEvent`.
53
+ - `close`: The dropdown closed.
54
+ - `search`: The user pressed `Enter` in the search field
55
+ - `reset`: The form was reset
56
+
57
+ Read from event:
58
+ - `event.target.changed`: tells whether the value differs from when it opened
59
+ - `event.target.query`: tells the search field current text
60
+ - `event.target.query`: the combobox selection
61
+
62
+ Like native form controls, changes made by script dispatch no `change`.
63
+ Framework bindings that assume `input` means "the value changed" need the `change` event instead.
64
+
65
+ ### Attributes
66
+ All `<select>`'s attributes are supported, and few extra:
67
+ - `filterable`: Displays search field in picker; options are automatically filtered against input value
68
+ - `searchable`: Displays search field in picker; you listen for input events and implement own filter. Usefully for fuzzy search, etc.
69
+ - `placeholder`: The combobox own placeholder, shown when there is no selection
70
+ - `query`: The search field input value
71
+
72
+ ## Options API
73
+ Like `select.options`, `combobox.options` is a live collection too, but a fully compatible polyfill – `HTMLComboboxOptionsCollection`, because `HTMLOptionsCollection` can't be constructed or subclassed.
74
+ The only difference between `select.options` and `combobox.options`, is that `combobox.options` will false on `instanceof HTMLOptionsCollection` check.
75
+
76
+ ## Accessibility
77
+ - Roles and states are set through `ElementInternals`, and mirrored as `role` and `aria`attributes, so tools that read the DOM can see it too.
78
+ - The host itself takes focus, so screen readers announce the combobox with its label and value.
79
+ - The arrow keys, Home/End, PageUp/PageDown, Enter, Space, Escape and type-ahead work as in the [select-only combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/).
80
+ - Name it with a `<label>`, `aria-label` or `aria-labelledby`.
81
+ - With `searchable` or `filterable` attributes, the popup is a `dialog` holding the search field and the listbox.
82
+ - With `clearable` and/or `multiple` attribute, the selected values are a list, each with a "Remove <OptionLabel>" button. Those texts can be localized:
83
+ ```ts
84
+ import { HTMLComboboxElement } from 'html-combobox-element';
19
85
 
20
- This isn't specific to this package; it affects any library adding custom elements to `JSX.IntrinsicElements` under the modern automatic JSX runtime.
86
+ HTMLComboboxElement.strings.removeTag = 'Удалить'; // "Удалить Банан"
87
+ HTMLComboboxElement.strings.clearAll = 'Очистить выбор';
88
+ ```
21
89
 
22
- So, in addition to `import 'html-combobox-element'`, create and include one small file tsconfig.json, with the snippet for your framework:
90
+ ### Example of produced accessibility tree in Chrome
91
+ ```
92
+ combobox "Fruit" value="Banana" expanded=true hasPopup=listbox controls=…
93
+ listbox (multiselectable with `multiple`)
94
+ option "Apple" selected=false
95
+ option "Banana" selected=true
96
+ option "Cherry" disabled=true
97
+ ```
23
98
 
24
- <details>
25
- <summary><strong>React</strong> (<code>jsx: "react-jsx"</code>)</summary>
99
+ ### Known Chrome limitation
100
+ Chrome does not expose the value of a disabled `<combo-box>`, because it computes the value of an ARIA combobox only while it is focusable.
26
101
 
102
+ ## Testing with Playwright
103
+ Playwright's `getByRole()` / `ariaSnapshot()` and Testing Library's `*ByRole` work:
27
104
  ```ts
28
- // combo-box.d.ts
29
- declare module 'react/jsx-runtime' {
30
- namespace JSX {
31
- interface IntrinsicElements {
32
- 'combo-box': ComboboxElementAttributes;
33
- 'box-option': ComboboxOptionElementAttributes;
34
- 'box-tag': ComboboxTagElementAttributes;
35
- }
36
- }
37
- }
38
- declare module 'react/jsx-dev-runtime' {
39
- namespace JSX {
40
- interface IntrinsicElements {
41
- 'combo-box': ComboboxElementAttributes;
42
- 'box-option': ComboboxOptionElementAttributes;
43
- 'box-tag': ComboboxTagElementAttributes;
44
- }
45
- }
46
- }
105
+ await page.getByRole('combobox').focus();
106
+ await page.getByRole('option', { name: 'Cherry' }).click();
107
+ await expect(page.getByRole('option', { selected: true })).toHaveText('Cherry');
47
108
  ```
48
109
 
49
- </details>
110
+ Playwright's `selectOption()`, `toHaveValue()`, `toHaveValues()` and `getByLabel()` work on `<combo-box>` like on `<select>` once you register in your fixtures the helper from `html-combobox-element/playwright`:
111
+ ```ts
112
+ // fixtures.ts
113
+ import { test as base } from '@playwright/test';
114
+ import { registerCombobox } from 'html-combobox-element/playwright';
115
+
116
+ export const test = base.extend<{}, { combobox: void }>({
117
+ combobox: [async ({ playwright }, use) => {
118
+ await registerCombobox(playwright.selectors);
119
+ await use();
120
+ }, { scope: 'worker', auto: true }],
121
+ });
122
+ export { expect } from '@playwright/test';
123
+ ```
124
+
125
+ ```ts
126
+ // any.spec.ts
127
+ import { test, expect } from './fixtures';
128
+
129
+ test('pick a fruit', async ({ page }) => {
130
+ await page.getByLabel('Fruit').selectOption('banana'); // by value or label
131
+ await page.getByLabel('Fruits').selectOption(['apple', 'cherry']);
132
+ await expect(page.getByLabel('Fruit')).toHaveValue('banana');
133
+ await expect(page.getByLabel('Fruits')).toHaveValues(['apple', 'cherry']);
134
+ });
135
+ ```
136
+
137
+ ### Why helper is needed
138
+ Playwright runs its own page-side code in an **isolated** JavaScript world,
139
+ which shares the DOM with your page _but not its JavaScript_, so the `HTMLComboboxElement` class doesn't exist there.
140
+ The helper registers a selector engine with `contentScript: true`, which is the Playwright's public way to run code in that world.
141
+ That teaches it the `<select>` API of `<combo-box>` through the DOM only. Your page's own JavaScript is **not touched**.
142
+
143
+ ### One difference with screen readers
144
+ Playwright names a custom element from `<label for>` only for native controls,
145
+ so query `getByRole('combobox', { name })` with `aria-label` or `aria-labelledby` on the `<combo-box>`, or without a name.
146
+
147
+ ## TypeScript and JSX
148
+ `import 'html-combobox-element'` also brings the typings: `document.createElement('combo-box')`, `querySelector`, `addEventListener` with the combobox's own events (`change`, `input`, `close`, `search`, `reset`, with a typed `event.target`) and `instanceof HTMLComboboxElement` all use the same `HTMLComboboxElement` type.
149
+
150
+ ### React
151
+ Nothing to add: `<combo-box>`, `<box-option>` and `<box-tag>` are typed with React's own types (`ref`, `style`, every DOM handler, `aria-*`...), for `jsx: "react-jsx"`, `"react-jsxdev"` and classic `"react"`.
152
+
153
+ React wraps events in its own SyntheticEvents, so handlers are typed accordingly:
154
+ - `onChange`, `onInput`, `onFocus`, `onClick`, ... receive a SyntheticEvent: `event.target` is the `<combo-box>` (`event.target.value`, `event.target.query`), `event.nativeEvent` is the DOM event.
155
+ - Events React doesn't know are attached by React 19 as native listeners under the exact, case-sensitive name: use `onclose` and `onsearch` (lowercase). `onClose`/`onSearch` never fire, and are type errors.
156
+
157
+ ```tsx
158
+ <combo-box
159
+ name="fruit"
160
+ multiple
161
+ onChange={(event) => setFruits(event.target.valueAsArray)}
162
+ onclose={(event) => event.target.changed && save()}
163
+ >
164
+ <box-option value="apple">Apple</box-option>
165
+ </combo-box>
166
+ ```
167
+
168
+ With `@types/react` 18 the framework-agnostic typings are used, as in previous versions.
169
+
170
+ If you copied the React `combo-box.d.ts` snippet from the README of a previous version, delete it: it still compiles, but it keeps the old typings.
171
+
172
+ ### Monorepos
173
+ The typings are part of a TypeScript program only if the program includes this package. A workspace package that renders `<combo-box>` but leaves the import to the app shell type-checks inside the app, but not on its own (its CI, editor, project references). Add a type-only import there - it's removed from the JS output, nothing is registered twice:
174
+ ```ts
175
+ import type {} from 'html-combobox-element';
176
+ ```
177
+
178
+ ### Other frameworks
179
+ JSX/template type-checking for these needs one small file in your project, with the snippet for your framework:
50
180
 
51
181
  <details>
52
182
  <summary><strong>Preact</strong> (<code>jsx: "react-jsx"</code>, <code>jsxImportSource: "preact"</code>)</summary>
@@ -107,17 +237,3 @@ required here (without it the file is treated as a script, not a module, and the
107
237
  augmentation breaks Vue's own types).
108
238
 
109
239
  </details>
110
-
111
- ### Exported types
112
-
113
- - `HTMLComboboxElement`, `HTMLComboboxOptionElement`, `HTMLComboboxTagElement` - element
114
- interfaces, also usable via `document.querySelector('combo-box')` etc.
115
- - `ComboboxEvent`, `ComboboxInputEvent`, `ComboboxFocusEvent` - typed event objects
116
- (`event.target` is a properly typed `HTMLComboboxElement`).
117
- - `ComboboxJsxAttributes`, `ComboboxOptionJsxAttributes` - the component's own props
118
- (`value`, `multiple`, `placeholder`, `clearable`, `searchable`, `filterable`,
119
- `required`, `disabled`, etc.), framework-independent.
120
- - `ComboboxElementAttributes`, `ComboboxOptionElementAttributes`,
121
- `ComboboxTagElementAttributes` - the above plus common passthrough attributes
122
- (`class`/`className`, `style`, `ref`, `children`, `data-*`, `aria-*`, `on*`). These
123
- are what the snippets above use for `'combo-box'` / `'box-option'` / `'box-tag'`.
@@ -2,6 +2,13 @@ import { HTMLComboboxTagElement } from './HTML.combobox.tag.element.js';
2
2
  import { HTMLComboboxOptionElement } from './HTML.combobox.option.element.js';
3
3
  export declare class ComboboxMarkup {
4
4
  #private;
5
+ /** Texts used only by assistive technology. Override them to localize, e.g. `HTMLComboboxElement.strings.clearAll = 'Очистить'` */
6
+ static strings: {
7
+ /** Accessible name of the clear-all button */
8
+ clearAll: string;
9
+ /** Accessible name prefix of a tag's clear button: "Remove Banana" */
10
+ removeTag: string;
11
+ };
5
12
  tagsContainer: HTMLDivElement | null;
6
13
  optionsContainer: HTMLDivElement | null;
7
14
  clearAllButton: HTMLButtonElement | null;
@@ -9,16 +16,30 @@ export declare class ComboboxMarkup {
9
16
  placeholder: HTMLDivElement | null;
10
17
  searchInput: HTMLInputElement | null;
11
18
  tagTemplate: HTMLComboboxTagElement | null;
19
+ internal: HTMLDivElement | null;
20
+ valueText: HTMLSpanElement | null;
21
+ /** Set by the host when it (or its fieldset) is disabled. */
22
+ disabled: boolean;
12
23
  constructor(shadowRoot: ShadowRoot, internals: ElementInternals);
13
24
  connect(): void;
14
25
  onHostConnected(): void;
15
26
  disconnect(): void;
16
27
  setPlaceholder(value: string): void;
17
28
  setInputValue(value: string): void;
18
- onClearAllFocusOut: (event: FocusEvent) => void;
29
+ /** The combobox value exposed to assistive technology is the text of the selected tags. */
30
+ syncValueText: () => void;
31
+ tagLabel(tag: HTMLComboboxTagElement): string;
32
+ static optionLabel(option: HTMLComboboxOptionElement): string;
19
33
  onClickOutside: (ev: MouseEvent) => void;
20
34
  sort(query: string): void;
21
35
  get options(): NodeListOf<HTMLComboboxOptionElement>;
36
+ /** Options reachable with the keyboard: not disabled (itself or by its optgroup) and not filtered out. */
37
+ get navigableOptions(): HTMLComboboxOptionElement[];
38
+ static isOptionDisabled(option: HTMLComboboxOptionElement): boolean;
39
+ get hasSearch(): boolean;
40
+ get expanded(): boolean;
41
+ /** Moves focus to the host without reopening the popup. */
42
+ focusHost(): void;
22
43
  getTagControl(tag: HTMLComboboxTagElement): HTMLButtonElement;
23
44
  setDropdownPosition(): void;
24
45
  showDropdown(): void;
@@ -3,6 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ComboboxMarkup = void 0;
4
4
  const HTML_combobox_tag_element_js_1 = require("./HTML.combobox.tag.element.js");
5
5
  class ComboboxMarkup {
6
+ /** Texts used only by assistive technology. Override them to localize, e.g. `HTMLComboboxElement.strings.clearAll = 'Очистить'` */
7
+ static strings = {
8
+ /** Accessible name of the clear-all button */
9
+ clearAll: 'Clear selection',
10
+ /** Accessible name prefix of a tag's clear button: "Remove Banana" */
11
+ removeTag: 'Remove',
12
+ };
6
13
  #shadowRoot;
7
14
  #internals;
8
15
  tagsContainer = null;
@@ -12,30 +19,37 @@ class ComboboxMarkup {
12
19
  placeholder = null;
13
20
  searchInput = null;
14
21
  tagTemplate = null;
22
+ internal = null;
23
+ valueText = null;
15
24
  #rso;
25
+ #tagsObserver;
16
26
  #hostWidth = 0;
17
27
  #hostHeight = 0;
18
28
  #hostTop = 0;
29
+ /** Set while focus is moved back to the host programmatically, so that focusin doesn't reopen the popup. */
30
+ #suppressOpen = false;
31
+ /** Set by the host when it (or its fieldset) is disabled. */
32
+ disabled = false;
19
33
  constructor(shadowRoot, internals) {
20
34
  this.#shadowRoot = shadowRoot;
21
35
  this.#internals = internals;
22
- window.addEventListener('click', this.onClickOutside);
23
- this.#shadowRoot.host.addEventListener('focusin', this.onFocusIn);
24
- this.#shadowRoot.host.addEventListener('focusout', this.onFocusOut);
36
+ this.#internals.ariaExpanded = 'false';
25
37
  this.#rso = new ResizeObserver(this.#onDimensionsChanges);
38
+ this.#tagsObserver = new MutationObserver(this.syncValueText);
26
39
  }
27
40
  connect() {
28
41
  const placeholder = this.#shadowRoot.host.getAttribute('placeholder') || `Выбрать`;
29
42
  this.tagsContainer = this.#shadowRoot.querySelector('#tags');
30
- this.#rso.observe(this.#shadowRoot.host, { box: 'border-box' });
43
+ this.internal = this.#shadowRoot.querySelector('#internal');
44
+ this.valueText = this.#shadowRoot.querySelector('#value');
31
45
  this.optionsContainer = this.#shadowRoot.querySelector('[part*="options"]');
32
46
  this.clearAllButton = this.#shadowRoot.querySelector('[part*="clear-all"]');
33
- this.clearAllButton?.addEventListener('focusout', this.onClearAllFocusOut);
47
+ this.clearAllButton?.setAttribute('aria-label', ComboboxMarkup.strings.clearAll);
34
48
  this.dropdown = this.#shadowRoot.querySelector('#dropdown');
35
49
  this.placeholder = this.#shadowRoot.querySelector('#placeholder');
36
50
  this.placeholder.innerText = placeholder;
37
51
  this.searchInput = this.#shadowRoot.querySelector('[part*="search-input"]');
38
- this.searchInput.value = this.#shadowRoot.host.getAttribute('query');
52
+ this.searchInput.value = this.#shadowRoot.host.getAttribute('query') ?? '';
39
53
  this.searchInput.style.setProperty('--search-input-placeholder-color', this.placeholder.style.color);
40
54
  const innerTemplate = this.#shadowRoot.querySelector('#tag-template');
41
55
  const doc = document.importNode(innerTemplate.content, true);
@@ -45,13 +59,20 @@ class ComboboxMarkup {
45
59
  const host = this.#shadowRoot.host;
46
60
  const clearable = host.hasAttribute('clearable') || host.hasAttribute('multiple');
47
61
  this.tagTemplate.toggleAttribute('visible', clearable);
62
+ window.addEventListener('click', this.onClickOutside);
63
+ host.addEventListener('focusin', this.onFocusIn);
64
+ host.addEventListener('focusout', this.onFocusOut);
65
+ this.#rso.observe(host, { box: 'border-box' });
66
+ this.#tagsObserver.observe(this.tagsContainer, { childList: true });
67
+ this.syncValueText();
48
68
  }
49
69
  disconnect() {
50
- this.#shadowRoot.host.removeEventListener('focusin', this.onFocusIn);
51
- this.#shadowRoot.host.removeEventListener('focusout', this.onFocusOut);
52
- this.clearAllButton?.removeEventListener('focusout', this.onClearAllFocusOut);
70
+ const host = this.#shadowRoot.host;
71
+ host.removeEventListener('focusin', this.onFocusIn);
72
+ host.removeEventListener('focusout', this.onFocusOut);
53
73
  window.removeEventListener('click', this.onClickOutside);
54
- this.#rso.unobserve(this.#shadowRoot.host);
74
+ this.#rso.unobserve(host);
75
+ this.#tagsObserver.disconnect();
55
76
  }
56
77
  setPlaceholder(value) {
57
78
  if (this.placeholder) {
@@ -59,20 +80,31 @@ class ComboboxMarkup {
59
80
  }
60
81
  }
61
82
  setInputValue(value) {
62
- if (this.searchInput) {
83
+ // Typing writes the query attribute, which comes back here: don't reset the caret
84
+ if (this.searchInput && this.searchInput.value !== value) {
63
85
  this.searchInput.value = value;
64
86
  }
65
87
  }
66
- onClearAllFocusOut = (event) => {
67
- event.preventDefault();
68
- event.stopPropagation();
69
- if (event.relatedTarget == null) {
70
- const host = this.#shadowRoot.host;
71
- if (!host.hasAttribute('searchable') && !host.hasAttribute('filterable')) {
72
- this.closeDropdown();
73
- }
74
- }
88
+ /** The combobox value exposed to assistive technology is the text of the selected tags. */
89
+ syncValueText = () => {
90
+ if (!this.valueText || !this.tagsContainer)
91
+ return;
92
+ const labels = Array.from(this.tagsContainer.querySelectorAll('box-tag'), (tag) => this.tagLabel(tag));
93
+ const text = labels.filter(Boolean).join(', ');
94
+ if (this.valueText.textContent !== text)
95
+ this.valueText.textContent = text;
96
+ this.clearAllButton?.toggleAttribute('disabled', labels.length === 0);
75
97
  };
98
+ tagLabel(tag) {
99
+ const option = tag.value ? this.getOptionByValue(tag.value) : null;
100
+ if (option)
101
+ return ComboboxMarkup.optionLabel(option);
102
+ const label = tag.querySelector('[part*="tag-label"]') ?? tag;
103
+ return label.textContent?.trim() ?? '';
104
+ }
105
+ static optionLabel(option) {
106
+ return (option.label || option.textContent || option.value || '').trim();
107
+ }
76
108
  onClickOutside = (ev) => {
77
109
  if (this.#internals.ariaExpanded !== 'true')
78
110
  return;
@@ -99,6 +131,30 @@ class ComboboxMarkup {
99
131
  get options() {
100
132
  return this.optionsContainer.querySelectorAll('box-option');
101
133
  }
134
+ /** Options reachable with the keyboard: not disabled (itself or by its optgroup) and not filtered out. */
135
+ get navigableOptions() {
136
+ return Array.from(this.options).filter((option) => !ComboboxMarkup.isOptionDisabled(option) && option.style.display !== 'none' && !option.hidden);
137
+ }
138
+ static isOptionDisabled(option) {
139
+ return option.disabled || Boolean(option.closest('optgroup')?.disabled);
140
+ }
141
+ get hasSearch() {
142
+ const host = this.#shadowRoot.host;
143
+ return host.hasAttribute('searchable') || host.hasAttribute('filterable');
144
+ }
145
+ get expanded() {
146
+ return this.#internals.ariaExpanded === 'true';
147
+ }
148
+ /** Moves focus to the host without reopening the popup. */
149
+ focusHost() {
150
+ this.#suppressOpen = true;
151
+ try {
152
+ this.#shadowRoot.host.focus();
153
+ }
154
+ finally {
155
+ this.#suppressOpen = false;
156
+ }
157
+ }
102
158
  getTagControl(tag) {
103
159
  return tag.querySelector('[part*="clear-tag"]');
104
160
  }
@@ -126,10 +182,15 @@ class ComboboxMarkup {
126
182
  dropdown.style.maxHeight = `${Math.max(100, maxHeight)}px`;
127
183
  }
128
184
  showDropdown() {
129
- this.onFocusIn();
185
+ this.#open();
130
186
  }
131
187
  onFocusIn = () => {
132
- if (this.#internals.ariaExpanded === "true")
188
+ if (this.#suppressOpen)
189
+ return;
190
+ this.#open();
191
+ };
192
+ #open() {
193
+ if (this.disabled || this.expanded)
133
194
  return;
134
195
  this.#hostTop = this.#shadowRoot.host.getBoundingClientRect().top;
135
196
  try {
@@ -138,9 +199,25 @@ class ComboboxMarkup {
138
199
  }
139
200
  catch (e) {
140
201
  this.#internals.ariaExpanded = "false";
202
+ return;
141
203
  }
142
- };
204
+ // When the combobox has focus and the dialog opens, move into the search field (if it asks
205
+ // for autofocus). The popover's own autofocus doesn't apply when the host is the focus target,
206
+ // and focusing synchronously from focusin would be undone by the host's own focus, so defer.
207
+ if (this.hasSearch && this.searchInput?.hasAttribute('autofocus')) {
208
+ setTimeout(() => {
209
+ const host = this.#shadowRoot.host;
210
+ const hostFocused = document.activeElement === host && !this.#shadowRoot.activeElement;
211
+ if (hostFocused && this.expanded && this.searchInput?.hasAttribute('autofocus'))
212
+ this.searchInput.focus();
213
+ });
214
+ }
215
+ }
143
216
  closeDropdown() {
217
+ if (!this.dropdown.matches(':popover-open')) {
218
+ this.#internals.ariaExpanded = "false";
219
+ return;
220
+ }
144
221
  try {
145
222
  this.dropdown.hidePopover();
146
223
  this.#internals.ariaExpanded = "false";
@@ -149,10 +226,17 @@ class ComboboxMarkup {
149
226
  this.#internals.ariaExpanded = "true";
150
227
  }
151
228
  }
229
+ // Focus moving between the host and its own shadow content is not "leaving" the combobox.
230
+ // Wait for the focus to settle, then close only if it is really gone.
152
231
  onFocusOut = () => {
153
- if (this.#internals.ariaExpanded !== "true")
232
+ if (!this.expanded)
154
233
  return;
155
- this.closeDropdown();
234
+ setTimeout(() => {
235
+ const host = this.#shadowRoot.host;
236
+ if (document.activeElement === host || host.matches(':focus-within'))
237
+ return;
238
+ this.closeDropdown();
239
+ });
156
240
  };
157
241
  createAndAppendTag(option) {
158
242
  const value = option.value;
@@ -184,16 +268,21 @@ class ComboboxMarkup {
184
268
  button = tag.querySelector('[part="clear-tag"]');
185
269
  }
186
270
  button?.setAttribute('value', value);
271
+ if (button && !button.hasAttribute('aria-label') && !button.hasAttribute('aria-labelledby')) {
272
+ button.setAttribute('aria-label', `${ComboboxMarkup.strings.removeTag} ${ComboboxMarkup.optionLabel(option)}`);
273
+ }
187
274
  tag.setAttribute('value', value);
188
275
  this.tagsContainer.appendChild(tag);
189
- this.clearAllButton?.removeAttribute('disabled');
276
+ this.syncValueText();
190
277
  return button;
191
278
  }
192
279
  getTagByValue(value) {
193
- return this.tagsContainer.querySelector(`box-tag[value="${value}"]`);
280
+ return this.tagsContainer.querySelector(`box-tag[value="${CSS.escape(value)}"]`);
194
281
  }
282
+ // Compared through the property, not a [value] selector: an option without a value attribute
283
+ // takes its text as value, like <option>
195
284
  getOptionByValue(value) {
196
- return this.optionsContainer.querySelector(`box-option[value="${value}"]`);
285
+ return Array.from(this.options).find((option) => option.value === value) ?? null;
197
286
  }
198
287
  get selectedOptions() {
199
288
  return this.optionsContainer.querySelectorAll('box-option[selected]');
@@ -214,9 +303,24 @@ ${urls.map(url => `@import "${url}";`)}
214
303
  position: relative;
215
304
  anchor-name: --position;
216
305
  }
306
+
307
+ :host(:state(disabled)) {
308
+ pointer-events: none;
309
+ }
217
310
 
218
311
 
219
312
 
313
+ /* Carries the accessible value of the combobox (the selected labels), like the
314
+ displayed text of a native <select>. Visually hidden, the tags show it visually. */
315
+ #value {
316
+ position: absolute;
317
+ inline-size: 1px;
318
+ block-size: 1px;
319
+ overflow: hidden;
320
+ clip-path: inset(50%);
321
+ white-space: nowrap;
322
+ }
323
+
220
324
  #internal {
221
325
  min-block-size: 1lh;
222
326
  display: grid;
@@ -273,7 +377,6 @@ ${urls.map(url => `@import "${url}";`)}
273
377
  box-option {
274
378
  display: flex;
275
379
  border-radius: inherit;
276
- content-visibility: auto;
277
380
  cursor: pointer;
278
381
  padding: inherit;
279
382
  padding-inline: .5lh;
@@ -283,6 +386,23 @@ ${urls.map(url => `@import "${url}";`)}
283
386
  background-color: color-mix(in srgb, Highlight, transparent 70%);
284
387
  }
285
388
 
389
+ /* Like <option label="Plum"></option>: an empty option displays its label, while its text and
390
+ value stay empty. Generated content is part of the option's accessible name. */
391
+ box-option[label]:empty::before {
392
+ content: attr(label);
393
+ }
394
+
395
+ box-option[disabled] {
396
+ color: GrayText;
397
+ cursor: not-allowed;
398
+ pointer-events: none;
399
+ }
400
+
401
+ box-option:focus-visible {
402
+ outline: 1px solid Highlight;
403
+ outline-offset: -1px;
404
+ }
405
+
286
406
  box-option[selected] {
287
407
  background-color: Highlight;
288
408
  cursor: not-allowed;
@@ -417,21 +537,22 @@ ${urls.map(url => `@import "${url}";`)}
417
537
  }
418
538
 
419
539
  </style>
420
- <div id='internal' part='internal'>
421
- <div id="tags" part='tags'></div>
422
- <div id="placeholder" part='placeholder' tabindex='0'>Выбрать</div>
423
- <button part="clear-all">✕</button>
540
+ <span id="value" part="value"></span>
541
+ <div id='internal' part='internal' role="group">
542
+ <div id="tags" part='tags' role="list"></div>
543
+ <div id="placeholder" part='placeholder' aria-hidden="true">Выбрать</div>
544
+ <button part="clear-all" type="button">✕</button>
424
545
  </div>
425
546
 
426
547
  <div id="dropdown" popover="manual" part='dropdown' >
427
- <input placeholder='Поиск' name="search-input" part="search-input" inputmode='search' autofocus />
548
+ <input placeholder='Поиск' name="search-input" part="search-input" inputmode='search' autocomplete="off" aria-controls="options" aria-autocomplete="list" autofocus />
428
549
  <div part="options" id='options' role="listbox"></div>
429
550
  </div>
430
551
 
431
552
  <template id='tag-template'>
432
553
  <box-tag>
433
554
  <span part="tag-label"></span>
434
- <div part="clear-tag" role='button' tabindex='0'>✕</div>
555
+ <div part="clear-tag" role='button' tabindex='0'><span aria-hidden="true">✕</span></div>
435
556
  </box-tag>
436
557
  </template>
437
558
  `;