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
@@ -1,13 +1,31 @@
1
+ import { HTMLComboboxElement as ComboboxElementClass } from './combobox/HTML.combobox.element.js';
2
+ import type { HTMLComboboxOptionsCollection } from './combobox/HTML.combobox.options.collection.js';
1
3
  export * from './combobox/index.js';
4
+ /**
5
+ * The `<combo-box>` constructor, typed the way lib.dom types built-in elements
6
+ * (`interface HTMLSelectElement` + `declare var HTMLSelectElement: { prototype; new() }`):
7
+ * the same `HTMLComboboxElement` type is what `document.createElement('combo-box')`,
8
+ * `querySelector('combo-box')`, `event.target`, refs and `instanceof HTMLComboboxElement` give.
9
+ *
10
+ * Runtime-wise this is the very same class object that `export *` exported before.
11
+ */
12
+ export declare const HTMLComboboxElement: Omit<typeof ComboboxElementClass, 'prototype'> & {
13
+ prototype: HTMLComboboxElement;
14
+ new (): HTMLComboboxElement;
15
+ };
2
16
  interface HTMLComboboxTagElement extends HTMLElement {
3
17
  }
4
18
  interface HTMLComboboxOptionElement extends HTMLOptionElement {
5
19
  value: string;
6
20
  label: string;
7
21
  }
8
- interface HTMLComboboxElement extends HTMLSelectElement {
22
+ export interface HTMLComboboxElement extends HTMLSelectElement {
9
23
  /** List of selected options value attribute */
10
24
  valueAsArray: string[];
25
+ /** Whether the value differs from when the picker opened (read it in a `close` listener). */
26
+ readonly changed: boolean;
27
+ /** A live `HTMLOptionsCollection`-compatible collection (not `instanceof HTMLOptionsCollection`). */
28
+ readonly options: HTMLOptionsCollection & HTMLComboboxOptionsCollection;
11
29
  multiple: boolean;
12
30
  /** The value of internal input
13
31
  *
@@ -45,11 +63,11 @@ interface HTMLComboboxElement extends HTMLSelectElement {
45
63
  * */
46
64
  filterable: boolean;
47
65
  selectedOptions: HTMLCollectionOf<HTMLComboboxOptionElement>;
48
- addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLComboboxElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
49
66
  addEventListener<K extends keyof ComboboxEventMap>(type: K, listener: (this: HTMLComboboxElement, ev: ComboboxEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
67
+ addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLComboboxElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
50
68
  addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
51
- removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLComboboxElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
52
69
  removeEventListener<K extends keyof ComboboxEventMap>(type: K, listener: (this: HTMLComboboxElement, ev: ComboboxEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
70
+ removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLComboboxElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
53
71
  removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
54
72
  }
55
73
  interface ComboboxEventMap extends HTMLElementEventMap {
@@ -62,6 +80,12 @@ interface ComboboxEventMap extends HTMLElementEventMap {
62
80
  "invalid": ComboboxEvent;
63
81
  "selectionchange": ComboboxEvent;
64
82
  "selectstart": ComboboxEvent;
83
+ /** The picker closed (read `event.target.changed`) */
84
+ "close": ComboboxEvent;
85
+ /** Enter was pressed in the search field */
86
+ "search": ComboboxEvent;
87
+ /** The owner form was reset */
88
+ "reset": ComboboxEvent;
65
89
  }
66
90
  /** Own attributes/properties of `<combo-box>`, shared by every JSX integration below. */
67
91
  export interface ComboboxJsxAttributes {
@@ -109,6 +133,10 @@ export interface ComboboxJsxAttributes {
109
133
  /** A Boolean attribute indicating that at least one option must be selected. */
110
134
  required?: boolean;
111
135
  disabled?: boolean;
136
+ /** The name the value is submitted under (`<combo-box>` is form-associated). */
137
+ name?: string;
138
+ /** The id of the `<form>` this combobox belongs to, when it isn't inside it. */
139
+ form?: string;
112
140
  }
113
141
  export interface ComboboxOptionJsxAttributes {
114
142
  value: string;
@@ -173,29 +201,45 @@ declare global {
173
201
  }
174
202
  }
175
203
  declare module 'react' {
176
- namespace JSX {
177
- interface IntrinsicElements {
178
- 'combo-box': ComboboxElementAttributes;
179
- 'box-option': ComboboxOptionElementAttributes;
180
- 'box-tag': ComboboxTagElementAttributes;
181
- }
204
+ /** A React event whose `target` is the element (events from the shadow DOM are retargeted to it). */
205
+ type ComboboxSyntheticEvent<T, E extends globalThis.Event = globalThis.Event> = import('react').SyntheticEvent<T, E> & {
206
+ target: EventTarget & T;
207
+ };
208
+ type ComboboxBaseHTMLAttributes<T> = import('react').HTMLAttributes<T>;
209
+ type ComboboxBaseHostAttributes<T> = Omit<import('react').HTMLAttributes<T>, 'onInput'>;
210
+ interface ComboboxHTMLAttributes<T> extends ComboboxBaseHostAttributes<T>, ComboboxJsxAttributes {
211
+ class?: string | undefined;
212
+ /** The user changed the selection (`event.target.value` is the new value). */
213
+ onChange?: import('react').EventHandler<ComboboxSyntheticEvent<T>> | undefined;
214
+ /** The user typed in the search field (`event.target.query`); `nativeEvent` is its InputEvent. */
215
+ onInput?: import('react').EventHandler<ComboboxSyntheticEvent<T, globalThis.InputEvent>> | undefined;
216
+ onInvalid?: import('react').EventHandler<ComboboxSyntheticEvent<T>> | undefined;
217
+ /** Native listener: the picker closed (`event.target.changed`). Lowercase - `onClose` never fires. */
218
+ onclose?: ((event: ComboboxEvent) => void) | undefined;
219
+ /** Native listener: Enter in the search field. Lowercase - `onSearch` never fires. */
220
+ onsearch?: ((event: ComboboxEvent) => void) | undefined;
182
221
  }
183
- }
184
- declare module 'react/jsx-runtime' {
185
- namespace JSX {
186
- interface IntrinsicElements {
187
- 'combo-box': ComboboxElementAttributes;
188
- 'box-option': ComboboxOptionElementAttributes;
189
- 'box-tag': ComboboxTagElementAttributes;
190
- }
222
+ interface ComboboxOptionHTMLAttributes<T> extends ComboboxBaseHTMLAttributes<T>, ComboboxOptionJsxAttributes {
223
+ class?: string | undefined;
191
224
  }
192
- }
193
- declare module 'react/jsx-dev-runtime' {
225
+ interface ComboboxTagHTMLAttributes<T> extends ComboboxBaseHTMLAttributes<T> {
226
+ class?: string | undefined;
227
+ }
228
+ /**
229
+ * @types/react 18 builds React.JSX.IntrinsicElements on top of the global JSX namespace, so
230
+ * its entries must stay assignable to the framework-agnostic global ones (catch-all `on*`,
231
+ * native event types): with React 18 types the global entries are kept as they are.
232
+ * @types/react 19 has no global JSX namespace: React.JSX gets React-typed props.
233
+ * (React 18 types declare the HTML elements in the global namespace, React 19 types don't.)
234
+ */
235
+ type ComboboxIntrinsicElement<Global, Typed> = globalThis.JSX.IntrinsicElements extends {
236
+ div: unknown;
237
+ } ? Global : Typed;
194
238
  namespace JSX {
195
239
  interface IntrinsicElements {
196
- 'combo-box': ComboboxElementAttributes;
197
- 'box-option': ComboboxOptionElementAttributes;
198
- 'box-tag': ComboboxTagElementAttributes;
240
+ 'combo-box': ComboboxIntrinsicElement<ComboboxElementAttributes, import('react').ClassAttributes<HTMLComboboxElement> & ComboboxHTMLAttributes<HTMLComboboxElement>>;
241
+ 'box-option': ComboboxIntrinsicElement<ComboboxOptionElementAttributes, import('react').ClassAttributes<HTMLComboboxOptionElement> & ComboboxOptionHTMLAttributes<HTMLComboboxOptionElement>>;
242
+ 'box-tag': ComboboxIntrinsicElement<ComboboxTagElementAttributes, import('react').ClassAttributes<HTMLComboboxTagElement> & ComboboxTagHTMLAttributes<HTMLComboboxTagElement>>;
199
243
  }
200
244
  }
201
245
  }
package/dist/cjs/index.js CHANGED
@@ -14,4 +14,15 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.HTMLComboboxElement = void 0;
18
+ const HTML_combobox_element_js_1 = require("./combobox/HTML.combobox.element.js");
17
19
  __exportStar(require("./combobox/index.js"), exports);
20
+ /**
21
+ * The `<combo-box>` constructor, typed the way lib.dom types built-in elements
22
+ * (`interface HTMLSelectElement` + `declare var HTMLSelectElement: { prototype; new() }`):
23
+ * the same `HTMLComboboxElement` type is what `document.createElement('combo-box')`,
24
+ * `querySelector('combo-box')`, `event.target`, refs and `instanceof HTMLComboboxElement` give.
25
+ *
26
+ * Runtime-wise this is the very same class object that `export *` exported before.
27
+ */
28
+ exports.HTMLComboboxElement = HTML_combobox_element_js_1.HTMLComboboxElement;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Makes Playwright's `selectOption()`, `toHaveValue()` and `toHaveValues()` work on `<combo-box>`
3
+ * as they do on `<select>`.
4
+ *
5
+ * Why it's needed: Playwright runs its page-side code in an isolated JavaScript world. That world
6
+ * shares the DOM with the page, but not the page's JavaScript: the `<combo-box>` class (its
7
+ * `options`, `value`, `multiple`, …) doesn't exist there, so Playwright sees a plain element and
8
+ * refuses it ("Element is not a <select> element").
9
+ *
10
+ * What it does: Playwright's custom selector engines registered with `contentScript: true` are
11
+ * evaluated in that isolated world. This "engine" selects nothing; evaluating it teaches the
12
+ * isolated world the `<select>` API of `<combo-box>`/`<box-option>`, implemented through the DOM
13
+ * only (attributes, shadow root). `<combo-box>` reacts to `selected` attributes changed from any
14
+ * world, so this drives the real component.
15
+ *
16
+ * It never touches the page's own world, so the application under test sees no difference.
17
+ *
18
+ * @example
19
+ * // fixtures.ts
20
+ * import { test as base } from '@playwright/test';
21
+ * import { registerCombobox } from 'html-combobox-element/playwright';
22
+ *
23
+ * export const test = base.extend<{}, { combobox: void }>({
24
+ * combobox: [async ({ playwright }, use) => {
25
+ * await registerCombobox(playwright.selectors);
26
+ * await use();
27
+ * }, { scope: 'worker', auto: true }],
28
+ * });
29
+ *
30
+ * // in a test
31
+ * await page.getByLabel('Fruit').selectOption('banana');
32
+ * await expect(page.getByLabel('Fruit')).toHaveValue('banana');
33
+ */
34
+ /** The part of Playwright's `Selectors` this module uses (no dependency on Playwright) */
35
+ export interface SelectorsLike {
36
+ register(name: string, script: string, options?: {
37
+ contentScript?: boolean;
38
+ }): Promise<void>;
39
+ }
40
+ /** Name under which the engine is registered by default */
41
+ export declare const COMBOBOX_ENGINE_NAME = "combobox-select-api";
42
+ /** Source of the selector engine, for `selectors.register(name, source, { contentScript: true })` */
43
+ export declare const comboboxSelectorEngine: string;
44
+ /**
45
+ * Registers the engine. Call it once per worker, before any page is created
46
+ * (Playwright requires selector engines to be registered before pages exist).
47
+ */
48
+ export declare function registerCombobox(selectors: SelectorsLike, name?: string): Promise<void>;
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /**
3
+ * Makes Playwright's `selectOption()`, `toHaveValue()` and `toHaveValues()` work on `<combo-box>`
4
+ * as they do on `<select>`.
5
+ *
6
+ * Why it's needed: Playwright runs its page-side code in an isolated JavaScript world. That world
7
+ * shares the DOM with the page, but not the page's JavaScript: the `<combo-box>` class (its
8
+ * `options`, `value`, `multiple`, …) doesn't exist there, so Playwright sees a plain element and
9
+ * refuses it ("Element is not a <select> element").
10
+ *
11
+ * What it does: Playwright's custom selector engines registered with `contentScript: true` are
12
+ * evaluated in that isolated world. This "engine" selects nothing; evaluating it teaches the
13
+ * isolated world the `<select>` API of `<combo-box>`/`<box-option>`, implemented through the DOM
14
+ * only (attributes, shadow root). `<combo-box>` reacts to `selected` attributes changed from any
15
+ * world, so this drives the real component.
16
+ *
17
+ * It never touches the page's own world, so the application under test sees no difference.
18
+ *
19
+ * @example
20
+ * // fixtures.ts
21
+ * import { test as base } from '@playwright/test';
22
+ * import { registerCombobox } from 'html-combobox-element/playwright';
23
+ *
24
+ * export const test = base.extend<{}, { combobox: void }>({
25
+ * combobox: [async ({ playwright }, use) => {
26
+ * await registerCombobox(playwright.selectors);
27
+ * await use();
28
+ * }, { scope: 'worker', auto: true }],
29
+ * });
30
+ *
31
+ * // in a test
32
+ * await page.getByLabel('Fruit').selectOption('banana');
33
+ * await expect(page.getByLabel('Fruit')).toHaveValue('banana');
34
+ */
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.comboboxSelectorEngine = exports.COMBOBOX_ENGINE_NAME = void 0;
37
+ exports.registerCombobox = registerCombobox;
38
+ /** Name under which the engine is registered by default */
39
+ exports.COMBOBOX_ENGINE_NAME = 'combobox-select-api';
40
+ // Evaluated inside Playwright's isolated world: must be self-contained (it's sent as source text).
41
+ function installComboboxSelectApi() {
42
+ const engine = { query: () => null, queryAll: () => [] };
43
+ // Playwright evaluates engines in the page's own world too: never patch there. Unlike the page,
44
+ // Chrome's isolated worlds have no custom element registry.
45
+ if (globalThis.customElements)
46
+ return engine;
47
+ const marker = Symbol.for('html-combobox-element/playwright');
48
+ if (globalThis[marker])
49
+ return engine;
50
+ globalThis[marker] = true;
51
+ const isCombobox = (el) => !!el && el.localName === 'combo-box';
52
+ const isOption = (el) => !!el && el.localName === 'box-option';
53
+ const text = (option) => (option.textContent || '').replace(/[\t\n\f\r ]+/g, ' ').trim();
54
+ // Same rules as HTMLComboboxOptionElement (and <option>)
55
+ const optionValue = (option) => option.getAttribute('value') ?? text(option);
56
+ const optionLabel = (option) => option.getAttribute('label') ?? text(option);
57
+ const optionsOf = (combobox) => Array.from(combobox.shadowRoot?.querySelectorAll('box-option') ?? []);
58
+ // Playwright checks `nodeName === 'SELECT'` before using the <select> API
59
+ const nodeName = Object.getOwnPropertyDescriptor(Node.prototype, 'nodeName');
60
+ Object.defineProperty(Node.prototype, 'nodeName', {
61
+ configurable: true,
62
+ get() { return isCombobox(this) ? 'SELECT' : nodeName.get.call(this); },
63
+ });
64
+ // On HTMLElement.prototype, so elements with their own native property (<select>, <input>,
65
+ // <option>, …) are unaffected; for every other element the getters return undefined as before.
66
+ Object.defineProperties(HTMLElement.prototype, {
67
+ options: {
68
+ configurable: true,
69
+ get() { return isCombobox(this) ? optionsOf(this) : undefined; },
70
+ },
71
+ selectedOptions: {
72
+ configurable: true,
73
+ get() { return isCombobox(this) ? optionsOf(this).filter((o) => o.hasAttribute('selected')) : undefined; },
74
+ },
75
+ multiple: {
76
+ configurable: true,
77
+ get() { return isCombobox(this) ? this.hasAttribute('multiple') : undefined; },
78
+ },
79
+ // For getByLabel(): <label for> / wrapping <label> (label.control is native, so it works here)
80
+ labels: {
81
+ configurable: true,
82
+ get() {
83
+ if (!isCombobox(this))
84
+ return undefined;
85
+ return Array.from(this.ownerDocument.querySelectorAll('label')).filter((label) => label.control === this);
86
+ },
87
+ },
88
+ label: {
89
+ configurable: true,
90
+ get() { return isOption(this) ? optionLabel(this) : undefined; },
91
+ },
92
+ selected: {
93
+ configurable: true,
94
+ get() { return isOption(this) ? this.hasAttribute('selected') : undefined; },
95
+ set(value) { if (isOption(this))
96
+ this.toggleAttribute('selected', !!value); },
97
+ },
98
+ value: {
99
+ configurable: true,
100
+ get() {
101
+ if (isOption(this))
102
+ return optionValue(this);
103
+ if (isCombobox(this))
104
+ return optionsOf(this).filter((o) => o.hasAttribute('selected')).map(optionValue).join(',');
105
+ return undefined;
106
+ },
107
+ // Like combobox.value = x: null/undefined clear, comma-separated values select several
108
+ set(value) {
109
+ if (!isCombobox(this))
110
+ return;
111
+ const values = value == null ? [] : String(value).split(',');
112
+ for (const option of optionsOf(this))
113
+ option.toggleAttribute('selected', values.includes(optionValue(option)));
114
+ },
115
+ },
116
+ });
117
+ return engine;
118
+ }
119
+ /** Source of the selector engine, for `selectors.register(name, source, { contentScript: true })` */
120
+ exports.comboboxSelectorEngine = `(${installComboboxSelectApi.toString()})()`;
121
+ /**
122
+ * Registers the engine. Call it once per worker, before any page is created
123
+ * (Playwright requires selector engines to be registered before pages exist).
124
+ */
125
+ function registerCombobox(selectors, name = exports.COMBOBOX_ENGINE_NAME) {
126
+ return selectors.register(name, exports.comboboxSelectorEngine, { contentScript: true });
127
+ }
@@ -1 +1 @@
1
- {"root":["../../src/index.ts","../../src/combobox/boolean.attribute.value.normalizer.ts","../../src/combobox/combobox.markup.ts","../../src/combobox/html.combobox.element.ts","../../src/combobox/html.combobox.option.element.ts","../../src/combobox/html.combobox.tag.element.ts","../../src/combobox/index.ts"],"version":"5.9.3"}
1
+ {"root":["../../src/index.ts","../../src/playwright.ts","../../src/combobox/boolean.attribute.value.normalizer.ts","../../src/combobox/combobox.markup.ts","../../src/combobox/html.combobox.element.ts","../../src/combobox/html.combobox.option.element.ts","../../src/combobox/html.combobox.options.collection.ts","../../src/combobox/html.combobox.tag.element.ts","../../src/combobox/index.ts"],"version":"5.9.3"}
@@ -1,5 +1,12 @@
1
1
  import { HTMLComboboxTagElement } from './HTML.combobox.tag.element.js';
2
2
  export class ComboboxMarkup {
3
+ /** Texts used only by assistive technology. Override them to localize, e.g. `HTMLComboboxElement.strings.clearAll = 'Очистить'` */
4
+ static strings = {
5
+ /** Accessible name of the clear-all button */
6
+ clearAll: 'Clear selection',
7
+ /** Accessible name prefix of a tag's clear button: "Remove Banana" */
8
+ removeTag: 'Remove',
9
+ };
3
10
  #shadowRoot;
4
11
  #internals;
5
12
  tagsContainer = null;
@@ -9,30 +16,37 @@ export class ComboboxMarkup {
9
16
  placeholder = null;
10
17
  searchInput = null;
11
18
  tagTemplate = null;
19
+ internal = null;
20
+ valueText = null;
12
21
  #rso;
22
+ #tagsObserver;
13
23
  #hostWidth = 0;
14
24
  #hostHeight = 0;
15
25
  #hostTop = 0;
26
+ /** Set while focus is moved back to the host programmatically, so that focusin doesn't reopen the popup. */
27
+ #suppressOpen = false;
28
+ /** Set by the host when it (or its fieldset) is disabled. */
29
+ disabled = false;
16
30
  constructor(shadowRoot, internals) {
17
31
  this.#shadowRoot = shadowRoot;
18
32
  this.#internals = internals;
19
- window.addEventListener('click', this.onClickOutside);
20
- this.#shadowRoot.host.addEventListener('focusin', this.onFocusIn);
21
- this.#shadowRoot.host.addEventListener('focusout', this.onFocusOut);
33
+ this.#internals.ariaExpanded = 'false';
22
34
  this.#rso = new ResizeObserver(this.#onDimensionsChanges);
35
+ this.#tagsObserver = new MutationObserver(this.syncValueText);
23
36
  }
24
37
  connect() {
25
38
  const placeholder = this.#shadowRoot.host.getAttribute('placeholder') || `Выбрать`;
26
39
  this.tagsContainer = this.#shadowRoot.querySelector('#tags');
27
- this.#rso.observe(this.#shadowRoot.host, { box: 'border-box' });
40
+ this.internal = this.#shadowRoot.querySelector('#internal');
41
+ this.valueText = this.#shadowRoot.querySelector('#value');
28
42
  this.optionsContainer = this.#shadowRoot.querySelector('[part*="options"]');
29
43
  this.clearAllButton = this.#shadowRoot.querySelector('[part*="clear-all"]');
30
- this.clearAllButton?.addEventListener('focusout', this.onClearAllFocusOut);
44
+ this.clearAllButton?.setAttribute('aria-label', ComboboxMarkup.strings.clearAll);
31
45
  this.dropdown = this.#shadowRoot.querySelector('#dropdown');
32
46
  this.placeholder = this.#shadowRoot.querySelector('#placeholder');
33
47
  this.placeholder.innerText = placeholder;
34
48
  this.searchInput = this.#shadowRoot.querySelector('[part*="search-input"]');
35
- this.searchInput.value = this.#shadowRoot.host.getAttribute('query');
49
+ this.searchInput.value = this.#shadowRoot.host.getAttribute('query') ?? '';
36
50
  this.searchInput.style.setProperty('--search-input-placeholder-color', this.placeholder.style.color);
37
51
  const innerTemplate = this.#shadowRoot.querySelector('#tag-template');
38
52
  const doc = document.importNode(innerTemplate.content, true);
@@ -42,13 +56,20 @@ export class ComboboxMarkup {
42
56
  const host = this.#shadowRoot.host;
43
57
  const clearable = host.hasAttribute('clearable') || host.hasAttribute('multiple');
44
58
  this.tagTemplate.toggleAttribute('visible', clearable);
59
+ window.addEventListener('click', this.onClickOutside);
60
+ host.addEventListener('focusin', this.onFocusIn);
61
+ host.addEventListener('focusout', this.onFocusOut);
62
+ this.#rso.observe(host, { box: 'border-box' });
63
+ this.#tagsObserver.observe(this.tagsContainer, { childList: true });
64
+ this.syncValueText();
45
65
  }
46
66
  disconnect() {
47
- this.#shadowRoot.host.removeEventListener('focusin', this.onFocusIn);
48
- this.#shadowRoot.host.removeEventListener('focusout', this.onFocusOut);
49
- this.clearAllButton?.removeEventListener('focusout', this.onClearAllFocusOut);
67
+ const host = this.#shadowRoot.host;
68
+ host.removeEventListener('focusin', this.onFocusIn);
69
+ host.removeEventListener('focusout', this.onFocusOut);
50
70
  window.removeEventListener('click', this.onClickOutside);
51
- this.#rso.unobserve(this.#shadowRoot.host);
71
+ this.#rso.unobserve(host);
72
+ this.#tagsObserver.disconnect();
52
73
  }
53
74
  setPlaceholder(value) {
54
75
  if (this.placeholder) {
@@ -56,20 +77,31 @@ export class ComboboxMarkup {
56
77
  }
57
78
  }
58
79
  setInputValue(value) {
59
- if (this.searchInput) {
80
+ // Typing writes the query attribute, which comes back here: don't reset the caret
81
+ if (this.searchInput && this.searchInput.value !== value) {
60
82
  this.searchInput.value = value;
61
83
  }
62
84
  }
63
- onClearAllFocusOut = (event) => {
64
- event.preventDefault();
65
- event.stopPropagation();
66
- if (event.relatedTarget == null) {
67
- const host = this.#shadowRoot.host;
68
- if (!host.hasAttribute('searchable') && !host.hasAttribute('filterable')) {
69
- this.closeDropdown();
70
- }
71
- }
85
+ /** The combobox value exposed to assistive technology is the text of the selected tags. */
86
+ syncValueText = () => {
87
+ if (!this.valueText || !this.tagsContainer)
88
+ return;
89
+ const labels = Array.from(this.tagsContainer.querySelectorAll('box-tag'), (tag) => this.tagLabel(tag));
90
+ const text = labels.filter(Boolean).join(', ');
91
+ if (this.valueText.textContent !== text)
92
+ this.valueText.textContent = text;
93
+ this.clearAllButton?.toggleAttribute('disabled', labels.length === 0);
72
94
  };
95
+ tagLabel(tag) {
96
+ const option = tag.value ? this.getOptionByValue(tag.value) : null;
97
+ if (option)
98
+ return ComboboxMarkup.optionLabel(option);
99
+ const label = tag.querySelector('[part*="tag-label"]') ?? tag;
100
+ return label.textContent?.trim() ?? '';
101
+ }
102
+ static optionLabel(option) {
103
+ return (option.label || option.textContent || option.value || '').trim();
104
+ }
73
105
  onClickOutside = (ev) => {
74
106
  if (this.#internals.ariaExpanded !== 'true')
75
107
  return;
@@ -96,6 +128,30 @@ export class ComboboxMarkup {
96
128
  get options() {
97
129
  return this.optionsContainer.querySelectorAll('box-option');
98
130
  }
131
+ /** Options reachable with the keyboard: not disabled (itself or by its optgroup) and not filtered out. */
132
+ get navigableOptions() {
133
+ return Array.from(this.options).filter((option) => !ComboboxMarkup.isOptionDisabled(option) && option.style.display !== 'none' && !option.hidden);
134
+ }
135
+ static isOptionDisabled(option) {
136
+ return option.disabled || Boolean(option.closest('optgroup')?.disabled);
137
+ }
138
+ get hasSearch() {
139
+ const host = this.#shadowRoot.host;
140
+ return host.hasAttribute('searchable') || host.hasAttribute('filterable');
141
+ }
142
+ get expanded() {
143
+ return this.#internals.ariaExpanded === 'true';
144
+ }
145
+ /** Moves focus to the host without reopening the popup. */
146
+ focusHost() {
147
+ this.#suppressOpen = true;
148
+ try {
149
+ this.#shadowRoot.host.focus();
150
+ }
151
+ finally {
152
+ this.#suppressOpen = false;
153
+ }
154
+ }
99
155
  getTagControl(tag) {
100
156
  return tag.querySelector('[part*="clear-tag"]');
101
157
  }
@@ -123,10 +179,15 @@ export class ComboboxMarkup {
123
179
  dropdown.style.maxHeight = `${Math.max(100, maxHeight)}px`;
124
180
  }
125
181
  showDropdown() {
126
- this.onFocusIn();
182
+ this.#open();
127
183
  }
128
184
  onFocusIn = () => {
129
- if (this.#internals.ariaExpanded === "true")
185
+ if (this.#suppressOpen)
186
+ return;
187
+ this.#open();
188
+ };
189
+ #open() {
190
+ if (this.disabled || this.expanded)
130
191
  return;
131
192
  this.#hostTop = this.#shadowRoot.host.getBoundingClientRect().top;
132
193
  try {
@@ -135,9 +196,25 @@ export class ComboboxMarkup {
135
196
  }
136
197
  catch (e) {
137
198
  this.#internals.ariaExpanded = "false";
199
+ return;
138
200
  }
139
- };
201
+ // When the combobox has focus and the dialog opens, move into the search field (if it asks
202
+ // for autofocus). The popover's own autofocus doesn't apply when the host is the focus target,
203
+ // and focusing synchronously from focusin would be undone by the host's own focus, so defer.
204
+ if (this.hasSearch && this.searchInput?.hasAttribute('autofocus')) {
205
+ setTimeout(() => {
206
+ const host = this.#shadowRoot.host;
207
+ const hostFocused = document.activeElement === host && !this.#shadowRoot.activeElement;
208
+ if (hostFocused && this.expanded && this.searchInput?.hasAttribute('autofocus'))
209
+ this.searchInput.focus();
210
+ });
211
+ }
212
+ }
140
213
  closeDropdown() {
214
+ if (!this.dropdown.matches(':popover-open')) {
215
+ this.#internals.ariaExpanded = "false";
216
+ return;
217
+ }
141
218
  try {
142
219
  this.dropdown.hidePopover();
143
220
  this.#internals.ariaExpanded = "false";
@@ -146,10 +223,17 @@ export class ComboboxMarkup {
146
223
  this.#internals.ariaExpanded = "true";
147
224
  }
148
225
  }
226
+ // Focus moving between the host and its own shadow content is not "leaving" the combobox.
227
+ // Wait for the focus to settle, then close only if it is really gone.
149
228
  onFocusOut = () => {
150
- if (this.#internals.ariaExpanded !== "true")
229
+ if (!this.expanded)
151
230
  return;
152
- this.closeDropdown();
231
+ setTimeout(() => {
232
+ const host = this.#shadowRoot.host;
233
+ if (document.activeElement === host || host.matches(':focus-within'))
234
+ return;
235
+ this.closeDropdown();
236
+ });
153
237
  };
154
238
  createAndAppendTag(option) {
155
239
  const value = option.value;
@@ -181,16 +265,21 @@ export class ComboboxMarkup {
181
265
  button = tag.querySelector('[part="clear-tag"]');
182
266
  }
183
267
  button?.setAttribute('value', value);
268
+ if (button && !button.hasAttribute('aria-label') && !button.hasAttribute('aria-labelledby')) {
269
+ button.setAttribute('aria-label', `${ComboboxMarkup.strings.removeTag} ${ComboboxMarkup.optionLabel(option)}`);
270
+ }
184
271
  tag.setAttribute('value', value);
185
272
  this.tagsContainer.appendChild(tag);
186
- this.clearAllButton?.removeAttribute('disabled');
273
+ this.syncValueText();
187
274
  return button;
188
275
  }
189
276
  getTagByValue(value) {
190
- return this.tagsContainer.querySelector(`box-tag[value="${value}"]`);
277
+ return this.tagsContainer.querySelector(`box-tag[value="${CSS.escape(value)}"]`);
191
278
  }
279
+ // Compared through the property, not a [value] selector: an option without a value attribute
280
+ // takes its text as value, like <option>
192
281
  getOptionByValue(value) {
193
- return this.optionsContainer.querySelector(`box-option[value="${value}"]`);
282
+ return Array.from(this.options).find((option) => option.value === value) ?? null;
194
283
  }
195
284
  get selectedOptions() {
196
285
  return this.optionsContainer.querySelectorAll('box-option[selected]');
@@ -211,9 +300,24 @@ ${urls.map(url => `@import "${url}";`)}
211
300
  position: relative;
212
301
  anchor-name: --position;
213
302
  }
303
+
304
+ :host(:state(disabled)) {
305
+ pointer-events: none;
306
+ }
214
307
 
215
308
 
216
309
 
310
+ /* Carries the accessible value of the combobox (the selected labels), like the
311
+ displayed text of a native <select>. Visually hidden, the tags show it visually. */
312
+ #value {
313
+ position: absolute;
314
+ inline-size: 1px;
315
+ block-size: 1px;
316
+ overflow: hidden;
317
+ clip-path: inset(50%);
318
+ white-space: nowrap;
319
+ }
320
+
217
321
  #internal {
218
322
  min-block-size: 1lh;
219
323
  display: grid;
@@ -270,7 +374,6 @@ ${urls.map(url => `@import "${url}";`)}
270
374
  box-option {
271
375
  display: flex;
272
376
  border-radius: inherit;
273
- content-visibility: auto;
274
377
  cursor: pointer;
275
378
  padding: inherit;
276
379
  padding-inline: .5lh;
@@ -280,6 +383,23 @@ ${urls.map(url => `@import "${url}";`)}
280
383
  background-color: color-mix(in srgb, Highlight, transparent 70%);
281
384
  }
282
385
 
386
+ /* Like <option label="Plum"></option>: an empty option displays its label, while its text and
387
+ value stay empty. Generated content is part of the option's accessible name. */
388
+ box-option[label]:empty::before {
389
+ content: attr(label);
390
+ }
391
+
392
+ box-option[disabled] {
393
+ color: GrayText;
394
+ cursor: not-allowed;
395
+ pointer-events: none;
396
+ }
397
+
398
+ box-option:focus-visible {
399
+ outline: 1px solid Highlight;
400
+ outline-offset: -1px;
401
+ }
402
+
283
403
  box-option[selected] {
284
404
  background-color: Highlight;
285
405
  cursor: not-allowed;
@@ -414,21 +534,22 @@ ${urls.map(url => `@import "${url}";`)}
414
534
  }
415
535
 
416
536
  </style>
417
- <div id='internal' part='internal'>
418
- <div id="tags" part='tags'></div>
419
- <div id="placeholder" part='placeholder' tabindex='0'>Выбрать</div>
420
- <button part="clear-all">✕</button>
537
+ <span id="value" part="value"></span>
538
+ <div id='internal' part='internal' role="group">
539
+ <div id="tags" part='tags' role="list"></div>
540
+ <div id="placeholder" part='placeholder' aria-hidden="true">Выбрать</div>
541
+ <button part="clear-all" type="button">✕</button>
421
542
  </div>
422
543
 
423
544
  <div id="dropdown" popover="manual" part='dropdown' >
424
- <input placeholder='Поиск' name="search-input" part="search-input" inputmode='search' autofocus />
545
+ <input placeholder='Поиск' name="search-input" part="search-input" inputmode='search' autocomplete="off" aria-controls="options" aria-autocomplete="list" autofocus />
425
546
  <div part="options" id='options' role="listbox"></div>
426
547
  </div>
427
548
 
428
549
  <template id='tag-template'>
429
550
  <box-tag>
430
551
  <span part="tag-label"></span>
431
- <div part="clear-tag" role='button' tabindex='0'>✕</div>
552
+ <div part="clear-tag" role='button' tabindex='0'><span aria-hidden="true">✕</span></div>
432
553
  </box-tag>
433
554
  </template>
434
555
  `;