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
|
@@ -1,96 +1,138 @@
|
|
|
1
1
|
import { toBoolean } from './Boolean.attribute.value.normalizer.js';
|
|
2
|
+
/**
|
|
3
|
+
* The `<option>` of `<combo-box>`.
|
|
4
|
+
*
|
|
5
|
+
* The browser only knows what `selected`/`disabled` mean on a native `<option>`, so the role and
|
|
6
|
+
* both states are set through ElementInternals, and mirrored as `role`, `aria-selected` and
|
|
7
|
+
* `aria-disabled` attributes. Internals are invisible from outside the element, so the attributes
|
|
8
|
+
* are what tools reading the DOM (Playwright's getByRole, Testing Library, axe) can see.
|
|
9
|
+
* Both say the same thing; for the browser an attribute takes precedence over internals.
|
|
10
|
+
*
|
|
11
|
+
* Attributes and properties:
|
|
12
|
+
* - Setters (and the setAttribute/removeAttribute overrides that route to them) only normalize
|
|
13
|
+
* the value and write the attribute.
|
|
14
|
+
* - attributeChangedCallback applies the side effects (state, ARIA, notifying the combobox).
|
|
15
|
+
* It's the one hook the browser calls for every way an attribute can change: the setters,
|
|
16
|
+
* the native Element.prototype methods, setAttributeNS/setAttributeNode, DevTools, and other
|
|
17
|
+
* JavaScript worlds (test runners, extensions) that can't see this class at all.
|
|
18
|
+
*/
|
|
2
19
|
export class HTMLComboboxOptionElement extends HTMLElement {
|
|
3
20
|
static OWN_IDL = new Set(['value', 'label', 'selected', 'disabled']);
|
|
21
|
+
static observedAttributes = ['selected', 'disabled'];
|
|
4
22
|
static EventInit = { bubbles: true, cancelable: true, composed: true };
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (!event.target ||
|
|
13
|
-
!(event.target instanceof HTMLComboboxOptionElement) ||
|
|
14
|
-
event.key === 'Tab' ||
|
|
15
|
-
event.key === 'Enter')
|
|
16
|
-
return;
|
|
17
|
-
event.preventDefault();
|
|
18
|
-
if (event.key === 'ArrowDown') {
|
|
19
|
-
const next = event.target.nextElementSibling;
|
|
20
|
-
if (next && next instanceof HTMLComboboxOptionElement) {
|
|
21
|
-
next.focus();
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
if (event.key === 'ArrowUp') {
|
|
25
|
-
const prev = event.target.previousElementSibling;
|
|
26
|
-
if (prev && prev instanceof HTMLComboboxOptionElement) {
|
|
27
|
-
prev.focus();
|
|
28
|
-
}
|
|
29
|
-
}
|
|
23
|
+
#internals;
|
|
24
|
+
/** The states the side effects were last applied for (compared in attributeChangedCallback) */
|
|
25
|
+
#applied = { selected: false, disabled: false };
|
|
26
|
+
constructor() {
|
|
27
|
+
super();
|
|
28
|
+
this.#internals = this.attachInternals();
|
|
29
|
+
this.#internals.role = 'option';
|
|
30
30
|
}
|
|
31
31
|
connectedCallback() {
|
|
32
|
-
this.addEventListener('keydown', HTMLComboboxOptionElement.onNavigate);
|
|
33
|
-
this.addEventListener('focusout', HTMLComboboxOptionElement.captureFocus);
|
|
34
|
-
this.addEventListener('click', this.#toggleSelection);
|
|
35
32
|
this.#initialAttributesSynchronization();
|
|
36
33
|
this.part.add('option');
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (!this.
|
|
40
|
-
|
|
34
|
+
// Focusable on click and from script (arrow keys), but not a tab stop: like native options,
|
|
35
|
+
// the list is entered with the arrow keys from the combobox, not with Tab.
|
|
36
|
+
if (!this.hasAttribute('tabindex'))
|
|
37
|
+
super.setAttribute('tabindex', '-1');
|
|
38
|
+
if (!this.hasAttribute('role'))
|
|
39
|
+
super.setAttribute('role', 'option');
|
|
40
|
+
this.#syncSelected(this.selected);
|
|
41
|
+
this.#syncDisabled(this.disabled);
|
|
42
|
+
// No label → value copy: like <option>, the value is the value attribute or the text.
|
|
43
|
+
// (<box-option label="Plum"></box-option> displays its label through CSS, see the markup styles.)
|
|
41
44
|
if (!this.textContent)
|
|
42
|
-
this.textContent = this.value;
|
|
45
|
+
this.textContent = this.getAttribute('value') ?? '';
|
|
43
46
|
this.dispatchEvent(new Event('optionselectedstatechange', HTMLComboboxOptionElement.EventInit));
|
|
44
47
|
}
|
|
45
|
-
|
|
46
|
-
this.removeEventListener('click', this.#toggleSelection);
|
|
47
|
-
this.removeEventListener('keydown', HTMLComboboxOptionElement.onNavigate);
|
|
48
|
-
this.removeEventListener('focusout', HTMLComboboxOptionElement.captureFocus);
|
|
49
|
-
}
|
|
50
|
-
#toggleSelection = (event) => {
|
|
51
|
-
event.stopPropagation();
|
|
52
|
-
this.selected = !this.selected;
|
|
53
|
-
};
|
|
48
|
+
/** Like <option>: the value attribute, or the text when there is none */
|
|
54
49
|
get value() {
|
|
55
|
-
return this.getAttribute('value');
|
|
50
|
+
return this.getAttribute('value') ?? this.text;
|
|
56
51
|
}
|
|
57
52
|
set value(value) {
|
|
58
53
|
if (value == null)
|
|
59
54
|
return;
|
|
60
55
|
super.setAttribute('value', String(value));
|
|
61
56
|
}
|
|
57
|
+
/** Like <option>: the label attribute when present, otherwise the text */
|
|
62
58
|
get label() {
|
|
63
|
-
return this.getAttribute('label');
|
|
59
|
+
return this.getAttribute('label') ?? this.text;
|
|
64
60
|
}
|
|
65
61
|
set label(value) {
|
|
66
62
|
if (value == null)
|
|
67
63
|
return;
|
|
68
64
|
super.setAttribute('label', String(value));
|
|
69
65
|
}
|
|
66
|
+
/** Like <option>: the text content with whitespace stripped and collapsed */
|
|
67
|
+
get text() {
|
|
68
|
+
return (this.textContent ?? '').replace(/[\t\n\f\r ]+/g, ' ').trim();
|
|
69
|
+
}
|
|
70
|
+
set text(value) {
|
|
71
|
+
this.textContent = value;
|
|
72
|
+
}
|
|
70
73
|
get selected() {
|
|
71
74
|
return this.hasAttribute('selected');
|
|
72
75
|
}
|
|
76
|
+
// Normalization only: the side effects run in attributeChangedCallback
|
|
73
77
|
set selected(value) {
|
|
74
|
-
|
|
75
|
-
const force = toBoolean(value);
|
|
76
|
-
if (prev === force) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
super.toggleAttribute('selected', force);
|
|
80
|
-
this.part.toggle('selected', force);
|
|
81
|
-
// console.log('this.isConnected', this.isConnected)
|
|
82
|
-
if (this.isConnected) {
|
|
83
|
-
this.dispatchEvent(new Event('optionselectedstatechange', HTMLComboboxOptionElement.EventInit));
|
|
84
|
-
}
|
|
78
|
+
super.toggleAttribute('selected', toBoolean(value));
|
|
85
79
|
}
|
|
86
80
|
get disabled() {
|
|
87
81
|
return this.hasAttribute('disabled');
|
|
88
82
|
}
|
|
89
83
|
set disabled(value) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
84
|
+
super.toggleAttribute('disabled', toBoolean(value));
|
|
85
|
+
}
|
|
86
|
+
attributeChangedCallback(name, _oldValue, newValue) {
|
|
87
|
+
// A path that skipped the setters can leave a value they'd never write, e.g. selected="false".
|
|
88
|
+
// Fix it once: the removal calls back again with a valid value (which applies the change);
|
|
89
|
+
// that removal is valid, so it isn't fixed again: no loop.
|
|
90
|
+
if (newValue !== null && !toBoolean(newValue)) {
|
|
91
|
+
super.removeAttribute(name);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const now = newValue !== null;
|
|
95
|
+
// Compared with the state last applied, not with the old attribute value: after a fix-up
|
|
96
|
+
// the old value is "false", which says nothing about what was applied before it.
|
|
97
|
+
if (this.#applied[name] === now)
|
|
98
|
+
return;
|
|
99
|
+
if (name === 'selected') {
|
|
100
|
+
this.#syncSelected(now);
|
|
101
|
+
if (this.isConnected) {
|
|
102
|
+
this.dispatchEvent(new Event('optionselectedstatechange', HTMLComboboxOptionElement.EventInit));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
this.#syncDisabled(now);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
#syncSelected(selected) {
|
|
110
|
+
this.#applied.selected = selected;
|
|
111
|
+
this.part.toggle('selected', selected);
|
|
112
|
+
this.#syncAriaSelected();
|
|
113
|
+
}
|
|
114
|
+
// A disabled option stays in the accessibility tree (like a native disabled <option>), it is
|
|
115
|
+
// only marked as disabled. `inert` would remove it from the tree altogether.
|
|
116
|
+
#syncDisabled(disabled) {
|
|
117
|
+
this.#applied.disabled = disabled;
|
|
118
|
+
this.part.toggle('disabled', disabled);
|
|
119
|
+
this.#internals.ariaDisabled = disabled ? 'true' : null;
|
|
120
|
+
if (disabled)
|
|
121
|
+
super.setAttribute('aria-disabled', 'true');
|
|
122
|
+
else
|
|
123
|
+
super.removeAttribute('aria-disabled');
|
|
124
|
+
this.#syncAriaSelected();
|
|
125
|
+
}
|
|
126
|
+
// Like a native <option>: a disabled option that isn't selected isn't selectable, so it reports
|
|
127
|
+
// no selected state at all (rather than selected=false).
|
|
128
|
+
#syncAriaSelected() {
|
|
129
|
+
const selected = this.selected;
|
|
130
|
+
const ariaSelected = this.disabled && !selected ? null : String(selected);
|
|
131
|
+
this.#internals.ariaSelected = ariaSelected;
|
|
132
|
+
if (ariaSelected === null)
|
|
133
|
+
super.removeAttribute('aria-selected');
|
|
134
|
+
else
|
|
135
|
+
super.setAttribute('aria-selected', ariaSelected);
|
|
94
136
|
}
|
|
95
137
|
#initialAttributesSynchronization() {
|
|
96
138
|
for (const key of HTMLComboboxOptionElement.OWN_IDL) {
|
|
@@ -106,18 +148,14 @@ export class HTMLComboboxOptionElement extends HTMLElement {
|
|
|
106
148
|
}
|
|
107
149
|
}
|
|
108
150
|
removeAttribute(name) {
|
|
109
|
-
|
|
110
|
-
|
|
151
|
+
// value/label setters ignore null (like option.value = null they'd need a string), so remove directly
|
|
152
|
+
if (name === 'selected' || name === 'disabled') {
|
|
153
|
+
this[name] = false;
|
|
111
154
|
}
|
|
112
155
|
else {
|
|
113
156
|
super.removeAttribute(name);
|
|
114
157
|
}
|
|
115
158
|
}
|
|
116
|
-
toggleAttribute(name, force) {
|
|
117
|
-
if (name === 'selected')
|
|
118
|
-
this.part.toggle(name, force);
|
|
119
|
-
return super.toggleAttribute(name, force);
|
|
120
|
-
}
|
|
121
159
|
}
|
|
122
160
|
if (!window.customElements.get('box-option')) {
|
|
123
161
|
window.customElements.define('box-option', HTMLComboboxOptionElement);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { HTMLComboboxOptionElement } from './HTML.combobox.option.element.js';
|
|
2
|
+
// Kept outside the instance: the collection is used through a Proxy, and private (#) fields
|
|
3
|
+
// can't be read through a Proxy receiver.
|
|
4
|
+
const sources = new WeakMap();
|
|
5
|
+
const source = (collection) => sources.get(collection);
|
|
6
|
+
const isIndex = (prop) => typeof prop === 'string' && /^(0|[1-9]\d*)$/.test(prop);
|
|
7
|
+
/**
|
|
8
|
+
* The `options` of a `<combo-box>`: an implementation of `HTMLOptionsCollection`, whose constructor
|
|
9
|
+
* is not exposed (it can't be constructed or subclassed). Same behaviour as `select.options`:
|
|
10
|
+
* it is live, supports `options[i]` (also as an assignment target), `length` (also settable),
|
|
11
|
+
* `item()`, `namedItem()`, `add()`, `remove()`, `selectedIndex` and iteration.
|
|
12
|
+
*/
|
|
13
|
+
export class HTMLComboboxOptionsCollection {
|
|
14
|
+
constructor(source) {
|
|
15
|
+
sources.set(this, source);
|
|
16
|
+
const proxy = new Proxy(this, {
|
|
17
|
+
get(target, prop, receiver) {
|
|
18
|
+
if (isIndex(prop))
|
|
19
|
+
return target.item(Number(prop)) ?? undefined;
|
|
20
|
+
if (typeof prop === 'symbol' || prop in target)
|
|
21
|
+
return Reflect.get(target, prop, receiver);
|
|
22
|
+
return target.namedItem(prop) ?? undefined;
|
|
23
|
+
},
|
|
24
|
+
set(target, prop, value, receiver) {
|
|
25
|
+
if (isIndex(prop)) {
|
|
26
|
+
target.#setIndexed(Number(prop), value);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return Reflect.set(target, prop, value, receiver);
|
|
30
|
+
},
|
|
31
|
+
has(target, prop) {
|
|
32
|
+
if (isIndex(prop))
|
|
33
|
+
return Number(prop) < target.length;
|
|
34
|
+
if (typeof prop === 'symbol' || prop in target)
|
|
35
|
+
return Reflect.has(target, prop);
|
|
36
|
+
return target.namedItem(prop) !== null;
|
|
37
|
+
},
|
|
38
|
+
ownKeys(target) {
|
|
39
|
+
const names = new Set();
|
|
40
|
+
for (const option of target) {
|
|
41
|
+
if (option.id)
|
|
42
|
+
names.add(option.id);
|
|
43
|
+
const name = option.getAttribute('name');
|
|
44
|
+
if (name)
|
|
45
|
+
names.add(name);
|
|
46
|
+
}
|
|
47
|
+
const indices = Array.from({ length: target.length }, (_, i) => String(i));
|
|
48
|
+
return [...indices, ...Array.from(names).filter((name) => !isIndex(name)), ...Reflect.ownKeys(target)];
|
|
49
|
+
},
|
|
50
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
51
|
+
if (isIndex(prop)) {
|
|
52
|
+
const value = target.item(Number(prop));
|
|
53
|
+
return value ? { value, enumerable: true, configurable: true, writable: true } : undefined;
|
|
54
|
+
}
|
|
55
|
+
if (typeof prop === 'string' && !(prop in target)) {
|
|
56
|
+
const value = target.namedItem(prop);
|
|
57
|
+
return value ? { value, enumerable: true, configurable: true, writable: false } : undefined;
|
|
58
|
+
}
|
|
59
|
+
return Reflect.getOwnPropertyDescriptor(target, prop);
|
|
60
|
+
},
|
|
61
|
+
deleteProperty(target, prop) {
|
|
62
|
+
// Like the native collection, indexed and named properties can't be deleted
|
|
63
|
+
if (isIndex(prop) || (typeof prop === 'string' && !(prop in target)))
|
|
64
|
+
return false;
|
|
65
|
+
return Reflect.deleteProperty(target, prop);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
sources.set(proxy, source);
|
|
69
|
+
return proxy;
|
|
70
|
+
}
|
|
71
|
+
get [Symbol.toStringTag]() {
|
|
72
|
+
return 'HTMLOptionsCollection';
|
|
73
|
+
}
|
|
74
|
+
*[Symbol.iterator]() {
|
|
75
|
+
yield* Array.from(source(this).options());
|
|
76
|
+
}
|
|
77
|
+
get length() {
|
|
78
|
+
return source(this).options().length;
|
|
79
|
+
}
|
|
80
|
+
/** Like `select.options.length = n`: appends blank options, or removes the last ones. */
|
|
81
|
+
set length(value) {
|
|
82
|
+
const length = Number(value) >>> 0;
|
|
83
|
+
const options = Array.from(source(this).options());
|
|
84
|
+
if (length > options.length) {
|
|
85
|
+
source(this).container().append(...HTMLComboboxOptionsCollection.#blankOptions(length - options.length));
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
options.slice(length).forEach((option) => option.remove());
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
item(index) {
|
|
92
|
+
return source(this).options().item(Number(index) >>> 0) ?? null;
|
|
93
|
+
}
|
|
94
|
+
/** The first option whose `id` or `name` attribute is `name` */
|
|
95
|
+
namedItem(name) {
|
|
96
|
+
if (name === '')
|
|
97
|
+
return null;
|
|
98
|
+
for (const option of this) {
|
|
99
|
+
if (option.id === name || option.getAttribute('name') === name)
|
|
100
|
+
return option;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Inserts an option (or optgroup) before `before` (an option/optgroup of this list, or an index),
|
|
106
|
+
* or at the end. Throws like `select.options.add()`.
|
|
107
|
+
*/
|
|
108
|
+
add(element, before) {
|
|
109
|
+
const isOption = element instanceof HTMLComboboxOptionElement || (element instanceof Element && element.localName === 'box-option');
|
|
110
|
+
if (!isOption && !(element instanceof HTMLOptGroupElement)) {
|
|
111
|
+
throw new TypeError(`Failed to execute 'add' on 'HTMLOptionsCollection': The provided value is not a <box-option> or an <optgroup>.`);
|
|
112
|
+
}
|
|
113
|
+
const container = source(this).container();
|
|
114
|
+
let reference = null;
|
|
115
|
+
if (typeof before === 'number') {
|
|
116
|
+
reference = this.item(before);
|
|
117
|
+
}
|
|
118
|
+
else if (before != null) {
|
|
119
|
+
if (!container.contains(before)) {
|
|
120
|
+
throw new DOMException(`Failed to execute 'add' on 'HTMLOptionsCollection': The node before which the new node is to be inserted is not a child of this node.`, 'NotFoundError');
|
|
121
|
+
}
|
|
122
|
+
reference = before;
|
|
123
|
+
}
|
|
124
|
+
if (element === reference)
|
|
125
|
+
return;
|
|
126
|
+
const parent = reference?.parentNode ?? container;
|
|
127
|
+
parent.insertBefore(element, reference);
|
|
128
|
+
}
|
|
129
|
+
/** Removes the option at `index` (does nothing when out of range) */
|
|
130
|
+
remove(index) {
|
|
131
|
+
this.item(index)?.remove();
|
|
132
|
+
}
|
|
133
|
+
/** Index of the first selected option, or -1. Setting it selects only that option. */
|
|
134
|
+
get selectedIndex() {
|
|
135
|
+
return Array.from(this).findIndex((option) => option.selected);
|
|
136
|
+
}
|
|
137
|
+
set selectedIndex(value) {
|
|
138
|
+
const index = Number(value);
|
|
139
|
+
const options = Array.from(this);
|
|
140
|
+
// Deselect first, so a single-select combobox doesn't see two selections at once
|
|
141
|
+
options.forEach((option, i) => { if (i !== index)
|
|
142
|
+
option.selected = false; });
|
|
143
|
+
if (options[index])
|
|
144
|
+
options[index].selected = true;
|
|
145
|
+
}
|
|
146
|
+
// `options[i] = option`, as specified for HTMLOptionsCollection: null removes the option at i;
|
|
147
|
+
// past the end, pads with blank options; otherwise replaces the option at i in place (inside its
|
|
148
|
+
// optgroup, if any). Chrome's native implementation diverges there: it moves the new option out
|
|
149
|
+
// of the optgroup, or throws when the next option is in one.
|
|
150
|
+
#setIndexed(index, value) {
|
|
151
|
+
if (value == null) {
|
|
152
|
+
this.remove(index);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!(value instanceof HTMLComboboxOptionElement) && !(value instanceof Element && value.localName === 'box-option')) {
|
|
156
|
+
throw new TypeError(`Failed to set an indexed property on 'HTMLOptionsCollection': The provided value is not a <box-option>.`);
|
|
157
|
+
}
|
|
158
|
+
const length = this.length;
|
|
159
|
+
if (index >= length) {
|
|
160
|
+
source(this).container().append(...HTMLComboboxOptionsCollection.#blankOptions(index - length), value);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const current = this.item(index);
|
|
164
|
+
if (current !== value)
|
|
165
|
+
current.parentNode.replaceChild(value, current);
|
|
166
|
+
}
|
|
167
|
+
static #blankOptions(count) {
|
|
168
|
+
return Array.from({ length: count }, () => document.createElement('box-option'));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -1,35 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A selected value shown inside `<combo-box>`. The tags are exposed as a list of items,
|
|
3
|
+
* each with a "Remove …" button when the combobox is `clearable` or `multiple`.
|
|
4
|
+
*/
|
|
1
5
|
export class HTMLComboboxTagElement extends HTMLElement {
|
|
2
6
|
static EventInit = { bubbles: true, cancelable: true, composed: true };
|
|
7
|
+
#internals;
|
|
8
|
+
constructor() {
|
|
9
|
+
super();
|
|
10
|
+
this.#internals = this.attachInternals();
|
|
11
|
+
this.#internals.role = 'listitem';
|
|
12
|
+
}
|
|
3
13
|
get value() {
|
|
4
14
|
return this.getAttribute('value') || '';
|
|
5
15
|
}
|
|
16
|
+
get #control() {
|
|
17
|
+
return this.querySelector('[part*="clear-tag"]');
|
|
18
|
+
}
|
|
6
19
|
connectedCallback() {
|
|
7
20
|
this.part.add('tag');
|
|
8
|
-
const control = this
|
|
9
|
-
if (!
|
|
10
|
-
|
|
21
|
+
const control = this.#control;
|
|
22
|
+
if (control && !(control instanceof HTMLButtonElement)) {
|
|
23
|
+
// A non-native button (e.g. <div part="clear-tag">) needs the button semantics and focusability added
|
|
24
|
+
if (!control.hasAttribute('role'))
|
|
25
|
+
control.setAttribute('role', 'button');
|
|
26
|
+
if (!control.hasAttribute('tabindex'))
|
|
27
|
+
control.setAttribute('tabindex', '0');
|
|
11
28
|
}
|
|
12
|
-
control?.addEventListener('focusout', HTMLComboboxTagElement.captureFocus);
|
|
13
29
|
control?.addEventListener('click', HTMLComboboxTagElement.onClearTag);
|
|
30
|
+
control?.addEventListener('keydown', HTMLComboboxTagElement.onControlKeyDown);
|
|
14
31
|
const root = this.getRootNode();
|
|
15
|
-
if (root?.host
|
|
32
|
+
if (root?.host?.hasAttribute('multiple')) {
|
|
16
33
|
if (!control) {
|
|
17
34
|
throw new Error(`A <button> with part="clear-tag" is required for <combo-box> with multiple attribute`);
|
|
18
35
|
}
|
|
19
36
|
}
|
|
20
37
|
}
|
|
21
38
|
disconnectedCallback() {
|
|
22
|
-
const control = this
|
|
23
|
-
control?.removeEventListener('focusout', HTMLComboboxTagElement.captureFocus);
|
|
39
|
+
const control = this.#control;
|
|
24
40
|
control?.removeEventListener('click', HTMLComboboxTagElement.onClearTag);
|
|
41
|
+
control?.removeEventListener('keydown', HTMLComboboxTagElement.onControlKeyDown);
|
|
25
42
|
}
|
|
26
43
|
static onClearTag(event) {
|
|
27
|
-
const
|
|
28
|
-
|
|
44
|
+
const control = event.currentTarget;
|
|
45
|
+
control?.closest('box-tag')?.dispatchEvent(new Event('tagcleared', HTMLComboboxTagElement.EventInit));
|
|
29
46
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
event.
|
|
47
|
+
// role="button" on a non-<button> doesn't give it keyboard activation, add it.
|
|
48
|
+
static onControlKeyDown(event) {
|
|
49
|
+
const control = event.currentTarget;
|
|
50
|
+
if (control instanceof HTMLButtonElement)
|
|
51
|
+
return;
|
|
52
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
event.stopPropagation();
|
|
55
|
+
control.click();
|
|
56
|
+
}
|
|
33
57
|
}
|
|
34
58
|
}
|
|
35
59
|
if (!window.customElements.get('box-tag')) {
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
/** Name under which the engine is registered by default */
|
|
35
|
+
export const COMBOBOX_ENGINE_NAME = 'combobox-select-api';
|
|
36
|
+
// Evaluated inside Playwright's isolated world: must be self-contained (it's sent as source text).
|
|
37
|
+
function installComboboxSelectApi() {
|
|
38
|
+
const engine = { query: () => null, queryAll: () => [] };
|
|
39
|
+
// Playwright evaluates engines in the page's own world too: never patch there. Unlike the page,
|
|
40
|
+
// Chrome's isolated worlds have no custom element registry.
|
|
41
|
+
if (globalThis.customElements)
|
|
42
|
+
return engine;
|
|
43
|
+
const marker = Symbol.for('html-combobox-element/playwright');
|
|
44
|
+
if (globalThis[marker])
|
|
45
|
+
return engine;
|
|
46
|
+
globalThis[marker] = true;
|
|
47
|
+
const isCombobox = (el) => !!el && el.localName === 'combo-box';
|
|
48
|
+
const isOption = (el) => !!el && el.localName === 'box-option';
|
|
49
|
+
const text = (option) => (option.textContent || '').replace(/[\t\n\f\r ]+/g, ' ').trim();
|
|
50
|
+
// Same rules as HTMLComboboxOptionElement (and <option>)
|
|
51
|
+
const optionValue = (option) => option.getAttribute('value') ?? text(option);
|
|
52
|
+
const optionLabel = (option) => option.getAttribute('label') ?? text(option);
|
|
53
|
+
const optionsOf = (combobox) => Array.from(combobox.shadowRoot?.querySelectorAll('box-option') ?? []);
|
|
54
|
+
// Playwright checks `nodeName === 'SELECT'` before using the <select> API
|
|
55
|
+
const nodeName = Object.getOwnPropertyDescriptor(Node.prototype, 'nodeName');
|
|
56
|
+
Object.defineProperty(Node.prototype, 'nodeName', {
|
|
57
|
+
configurable: true,
|
|
58
|
+
get() { return isCombobox(this) ? 'SELECT' : nodeName.get.call(this); },
|
|
59
|
+
});
|
|
60
|
+
// On HTMLElement.prototype, so elements with their own native property (<select>, <input>,
|
|
61
|
+
// <option>, …) are unaffected; for every other element the getters return undefined as before.
|
|
62
|
+
Object.defineProperties(HTMLElement.prototype, {
|
|
63
|
+
options: {
|
|
64
|
+
configurable: true,
|
|
65
|
+
get() { return isCombobox(this) ? optionsOf(this) : undefined; },
|
|
66
|
+
},
|
|
67
|
+
selectedOptions: {
|
|
68
|
+
configurable: true,
|
|
69
|
+
get() { return isCombobox(this) ? optionsOf(this).filter((o) => o.hasAttribute('selected')) : undefined; },
|
|
70
|
+
},
|
|
71
|
+
multiple: {
|
|
72
|
+
configurable: true,
|
|
73
|
+
get() { return isCombobox(this) ? this.hasAttribute('multiple') : undefined; },
|
|
74
|
+
},
|
|
75
|
+
// For getByLabel(): <label for> / wrapping <label> (label.control is native, so it works here)
|
|
76
|
+
labels: {
|
|
77
|
+
configurable: true,
|
|
78
|
+
get() {
|
|
79
|
+
if (!isCombobox(this))
|
|
80
|
+
return undefined;
|
|
81
|
+
return Array.from(this.ownerDocument.querySelectorAll('label')).filter((label) => label.control === this);
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
label: {
|
|
85
|
+
configurable: true,
|
|
86
|
+
get() { return isOption(this) ? optionLabel(this) : undefined; },
|
|
87
|
+
},
|
|
88
|
+
selected: {
|
|
89
|
+
configurable: true,
|
|
90
|
+
get() { return isOption(this) ? this.hasAttribute('selected') : undefined; },
|
|
91
|
+
set(value) { if (isOption(this))
|
|
92
|
+
this.toggleAttribute('selected', !!value); },
|
|
93
|
+
},
|
|
94
|
+
value: {
|
|
95
|
+
configurable: true,
|
|
96
|
+
get() {
|
|
97
|
+
if (isOption(this))
|
|
98
|
+
return optionValue(this);
|
|
99
|
+
if (isCombobox(this))
|
|
100
|
+
return optionsOf(this).filter((o) => o.hasAttribute('selected')).map(optionValue).join(',');
|
|
101
|
+
return undefined;
|
|
102
|
+
},
|
|
103
|
+
// Like combobox.value = x: null/undefined clear, comma-separated values select several
|
|
104
|
+
set(value) {
|
|
105
|
+
if (!isCombobox(this))
|
|
106
|
+
return;
|
|
107
|
+
const values = value == null ? [] : String(value).split(',');
|
|
108
|
+
for (const option of optionsOf(this))
|
|
109
|
+
option.toggleAttribute('selected', values.includes(optionValue(option)));
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
return engine;
|
|
114
|
+
}
|
|
115
|
+
/** Source of the selector engine, for `selectors.register(name, source, { contentScript: true })` */
|
|
116
|
+
export const comboboxSelectorEngine = `(${installComboboxSelectApi.toString()})()`;
|
|
117
|
+
/**
|
|
118
|
+
* Registers the engine. Call it once per worker, before any page is created
|
|
119
|
+
* (Playwright requires selector engines to be registered before pages exist).
|
|
120
|
+
*/
|
|
121
|
+
export function registerCombobox(selectors, name = COMBOBOX_ENGINE_NAME) {
|
|
122
|
+
return selectors.register(name, comboboxSelectorEngine, { contentScript: true });
|
|
123
|
+
}
|
|
@@ -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"}
|
|
@@ -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;
|