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.
- package/README.md +162 -46
- package/dist/cjs/combobox/Combobox.markup.d.ts +22 -1
- package/dist/cjs/combobox/Combobox.markup.js +156 -35
- package/dist/cjs/combobox/HTML.combobox.element.d.ts +32 -5
- package/dist/cjs/combobox/HTML.combobox.element.js +431 -98
- package/dist/cjs/combobox/HTML.combobox.option.element.d.ts +25 -4
- package/dist/cjs/combobox/HTML.combobox.option.element.js +104 -73
- 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/index.d.ts +66 -22
- package/dist/cjs/index.js +11 -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 +156 -35
- package/dist/esm/combobox/HTML.combobox.element.js +431 -98
- package/dist/esm/combobox/HTML.combobox.option.element.js +104 -73
- 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/index.js +10 -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/index.d.ts +66 -22
- package/dist/types/playwright.d.ts +48 -0
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/package.json +49 -5
package/dist/cjs/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 />
|
|
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
|
`;
|