html-combobox-element 0.0.4 → 0.0.6
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.
- package/README.md +139 -24
- package/dist/cjs/combobox/Combobox.markup.d.ts +22 -1
- package/dist/cjs/combobox/Combobox.markup.js +157 -36
- package/dist/cjs/combobox/HTML.combobox.element.d.ts +32 -5
- package/dist/cjs/combobox/HTML.combobox.element.js +438 -81
- package/dist/cjs/combobox/HTML.combobox.option.element.d.ts +25 -4
- package/dist/cjs/combobox/HTML.combobox.option.element.js +104 -66
- package/dist/cjs/combobox/HTML.combobox.options.collection.d.ts +37 -0
- package/dist/cjs/combobox/HTML.combobox.options.collection.js +176 -0
- package/dist/cjs/combobox/HTML.combobox.tag.element.d.ts +7 -1
- package/dist/cjs/combobox/HTML.combobox.tag.element.js +36 -12
- package/dist/cjs/combobox/index.d.ts +1 -0
- package/dist/cjs/combobox/index.js +1 -0
- package/dist/cjs/playwright.d.ts +48 -0
- package/dist/cjs/playwright.js +127 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/combobox/Combobox.markup.js +157 -36
- package/dist/esm/combobox/HTML.combobox.element.js +438 -81
- package/dist/esm/combobox/HTML.combobox.option.element.js +104 -66
- package/dist/esm/combobox/HTML.combobox.options.collection.js +170 -0
- package/dist/esm/combobox/HTML.combobox.tag.element.js +36 -12
- package/dist/esm/combobox/index.js +1 -0
- package/dist/esm/playwright.js +123 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/combobox/Combobox.markup.d.ts +22 -1
- package/dist/types/combobox/HTML.combobox.element.d.ts +32 -5
- package/dist/types/combobox/HTML.combobox.option.element.d.ts +25 -4
- package/dist/types/combobox/HTML.combobox.options.collection.d.ts +37 -0
- package/dist/types/combobox/HTML.combobox.tag.element.d.ts +7 -1
- package/dist/types/combobox/index.d.ts +1 -0
- package/dist/types/playwright.d.ts +48 -0
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/package.json +18 -5
package/README.md
CHANGED
|
@@ -1,25 +1,154 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
-
###
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
86
|
+
HTMLComboboxElement.strings.removeTag = 'Удалить'; // "Удалить Банан"
|
|
87
|
+
HTMLComboboxElement.strings.clearAll = 'Очистить выбор';
|
|
88
|
+
```
|
|
21
89
|
|
|
22
|
-
|
|
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
|
+
```
|
|
98
|
+
|
|
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.
|
|
101
|
+
|
|
102
|
+
## Testing with Playwright
|
|
103
|
+
Playwright's `getByRole()` / `ariaSnapshot()` and Testing Library's `*ByRole` work:
|
|
104
|
+
```ts
|
|
105
|
+
await page.getByRole('combobox').focus();
|
|
106
|
+
await page.getByRole('option', { name: 'Cherry' }).click();
|
|
107
|
+
await expect(page.getByRole('option', { selected: true })).toHaveText('Cherry');
|
|
108
|
+
```
|
|
109
|
+
|
|
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
|
+
JSX/template type-checking only picks up custom element typings from a file in your own project - not from a package's shipped `.d.ts`.
|
|
149
|
+
Due to this TypeScript limitation that affects any library adding custom elements to `JSX.IntrinsicElements` under the modern automatic JSX runtime – using `<combo-box>` in JSX or a Vue template needs one extra step.
|
|
150
|
+
|
|
151
|
+
So, in addition to `import 'html-combobox-element'`, create and include one small file in tsconfig.json, with the snippet for your framework:
|
|
23
152
|
|
|
24
153
|
<details>
|
|
25
154
|
<summary><strong>React</strong> (<code>jsx: "react-jsx"</code>)</summary>
|
|
@@ -107,17 +236,3 @@ required here (without it the file is treated as a script, not a module, and the
|
|
|
107
236
|
augmentation breaks Vue's own types).
|
|
108
237
|
|
|
109
238
|
</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
|
-
|
|
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
|
-
|
|
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
|
|
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?.
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
185
|
+
this.#open();
|
|
130
186
|
}
|
|
131
187
|
onFocusIn = () => {
|
|
132
|
-
if (this.#
|
|
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
|
|
232
|
+
if (!this.expanded)
|
|
154
233
|
return;
|
|
155
|
-
|
|
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.
|
|
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.
|
|
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
|
-
<
|
|
421
|
-
|
|
422
|
-
<div id="
|
|
423
|
-
<
|
|
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 />
|
|
428
|
-
<div part="options" id='options'></div>
|
|
548
|
+
<input placeholder='Поиск' name="search-input" part="search-input" inputmode='search' autocomplete="off" aria-controls="options" aria-autocomplete="list" autofocus />
|
|
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
|
`;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { HTMLComboboxOptionElement } from './HTML.combobox.option.element.js';
|
|
2
|
+
import { HTMLComboboxOptionsCollection } from './HTML.combobox.options.collection.js';
|
|
2
3
|
export declare class HTMLComboboxElement extends HTMLElement {
|
|
3
4
|
#private;
|
|
4
5
|
static OWN_IDL: Set<string>;
|
|
6
|
+
static observedAttributes: string[];
|
|
5
7
|
static observerOptions: {
|
|
6
8
|
childList: boolean;
|
|
7
9
|
};
|
|
@@ -9,6 +11,11 @@ export declare class HTMLComboboxElement extends HTMLElement {
|
|
|
9
11
|
static formAssociated: boolean;
|
|
10
12
|
shadowRoot: ShadowRoot;
|
|
11
13
|
constructor();
|
|
14
|
+
/** Texts used only by assistive technology, override them to localize. */
|
|
15
|
+
static get strings(): {
|
|
16
|
+
clearAll: string;
|
|
17
|
+
removeTag: string;
|
|
18
|
+
};
|
|
12
19
|
connectedCallback(): void;
|
|
13
20
|
insertBefore: <T extends Node>(node: T, referenceNode: Node | null) => T;
|
|
14
21
|
appendChild: <T extends Node>(node: T) => T;
|
|
@@ -19,8 +26,9 @@ export declare class HTMLComboboxElement extends HTMLElement {
|
|
|
19
26
|
replaceChild: <T extends Node>(newChild: Node, oldChild: T) => T;
|
|
20
27
|
get changed(): boolean;
|
|
21
28
|
disconnectedCallback(): void;
|
|
29
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
22
30
|
formResetCallback(): void;
|
|
23
|
-
formDisabledCallback(): void;
|
|
31
|
+
formDisabledCallback(disabled: boolean): void;
|
|
24
32
|
get valueAsArray(): string[];
|
|
25
33
|
get query(): string;
|
|
26
34
|
set query(value: string);
|
|
@@ -37,22 +45,41 @@ export declare class HTMLComboboxElement extends HTMLElement {
|
|
|
37
45
|
get form(): HTMLFormElement;
|
|
38
46
|
get labels(): NodeList;
|
|
39
47
|
get length(): number;
|
|
48
|
+
/** Like `select.length = n`: appends blank options, or removes the last ones */
|
|
49
|
+
set length(value: number);
|
|
40
50
|
get multiple(): boolean;
|
|
41
51
|
set multiple(value: boolean);
|
|
52
|
+
/** The options, as a live `HTMLOptionsCollection`-like list (like `select.options`) */
|
|
53
|
+
get options(): HTMLComboboxOptionsCollection;
|
|
42
54
|
get required(): boolean;
|
|
43
55
|
set required(value: boolean);
|
|
56
|
+
/** Index of the first selected option, or -1. Setting it selects only that option. */
|
|
57
|
+
get selectedIndex(): number;
|
|
58
|
+
set selectedIndex(value: number);
|
|
44
59
|
get selectedOptions(): NodeListOf<HTMLComboboxOptionElement>;
|
|
45
60
|
get type(): "select-multiple" | "select-one";
|
|
46
61
|
get validationMessage(): string;
|
|
47
62
|
get validity(): ValidityState;
|
|
48
63
|
get willValidate(): boolean;
|
|
49
|
-
get value(): string;
|
|
50
|
-
set value(value: string);
|
|
51
|
-
|
|
64
|
+
get value(): string | null | undefined;
|
|
65
|
+
set value(value: string | null | undefined);
|
|
66
|
+
/** Like `select.add()`: inserts an option or optgroup before `before` (an element or an index), or at the end */
|
|
67
|
+
add(element: HTMLComboboxOptionElement | HTMLOptGroupElement, before?: HTMLElement | number | null): void;
|
|
68
|
+
checkValidity(): boolean;
|
|
52
69
|
item(index: number): HTMLComboboxOptionElement;
|
|
53
|
-
|
|
70
|
+
/** The first option whose `id` or `name` attribute is `name` */
|
|
71
|
+
namedItem(name: string): HTMLComboboxOptionElement;
|
|
72
|
+
/**
|
|
73
|
+
* Overloaded like `select.remove()`: without an argument removes the combobox itself
|
|
74
|
+
* (`Element.remove()`), with an index removes that option.
|
|
75
|
+
*/
|
|
76
|
+
remove(): void;
|
|
77
|
+
remove(index: number): void;
|
|
78
|
+
reportValidity(): boolean;
|
|
54
79
|
setCustomValidity(message: string): void;
|
|
55
80
|
showPicker(): void;
|
|
81
|
+
/** Closes the dropdown, if open */
|
|
82
|
+
hidePicker(): void;
|
|
56
83
|
setAttribute(name: string, value: any): void;
|
|
57
84
|
removeAttribute(name: string): void;
|
|
58
85
|
static loadCssFromUrls(urls: string[]): void;
|