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
|
@@ -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
|
-
|
|
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
|
|
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?.
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
182
|
+
this.#open();
|
|
127
183
|
}
|
|
128
184
|
onFocusIn = () => {
|
|
129
|
-
if (this.#
|
|
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
|
|
229
|
+
if (!this.expanded)
|
|
151
230
|
return;
|
|
152
|
-
|
|
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.
|
|
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.
|
|
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
|
-
<
|
|
418
|
-
|
|
419
|
-
<div id="
|
|
420
|
-
<
|
|
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 />
|
|
425
|
-
<div part="options" id='options'></div>
|
|
545
|
+
<input placeholder='Поиск' name="search-input" part="search-input" inputmode='search' autocomplete="off" aria-controls="options" aria-autocomplete="list" autofocus />
|
|
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
|
`;
|