@itenthusiasm/custom-elements 0.0.1
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/Combobox/Combobox.css +100 -0
- package/Combobox/ComboboxField.d.ts +243 -0
- package/Combobox/ComboboxField.js +1182 -0
- package/Combobox/ComboboxListbox.d.ts +15 -0
- package/Combobox/ComboboxListbox.js +34 -0
- package/Combobox/ComboboxOption.d.ts +39 -0
- package/Combobox/ComboboxOption.js +144 -0
- package/Combobox/README.md +17 -0
- package/Combobox/SelectEnhancer.d.ts +27 -0
- package/Combobox/SelectEnhancer.js +185 -0
- package/Combobox/index.d.ts +4 -0
- package/Combobox/index.js +4 -0
- package/Combobox/types/dom.d.ts +10 -0
- package/Combobox/types/helpers.d.ts +7 -0
- package/Combobox/types/preact.d.ts +43 -0
- package/Combobox/types/react.d.ts +54 -0
- package/Combobox/types/solid.d.ts +45 -0
- package/Combobox/types/svelte.d.ts +43 -0
- package/Combobox/types/vue.d.ts +93 -0
- package/LICENSE +16 -0
- package/README.md +3 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +38 -0
- package/utils/dom.d.ts +10 -0
- package/utils/dom.js +13 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export default ComboboxListbox;
|
|
2
|
+
/** @import ComboboxField from "./ComboboxField.js" */
|
|
3
|
+
declare class ComboboxListbox extends HTMLElement {
|
|
4
|
+
static get observedAttributes(): readonly ["nomatchesmessage"];
|
|
5
|
+
/**
|
|
6
|
+
* @param {typeof ComboboxListbox.observedAttributes[number]} name
|
|
7
|
+
* @param {string | null} oldValue
|
|
8
|
+
* @param {string | null} newValue
|
|
9
|
+
* @returns {void}
|
|
10
|
+
*/
|
|
11
|
+
attributeChangedCallback(name: (typeof ComboboxListbox.observedAttributes)[number], oldValue: string | null, newValue: string | null): void;
|
|
12
|
+
/** "On Mount" for Custom Elements @returns {void} */
|
|
13
|
+
connectedCallback(): void;
|
|
14
|
+
#private;
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** @import ComboboxField from "./ComboboxField.js" */
|
|
2
|
+
|
|
3
|
+
class ComboboxListbox extends HTMLElement {
|
|
4
|
+
static get observedAttributes() {
|
|
5
|
+
return /** @type {const} */ (["nomatchesmessage"]);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {typeof ComboboxListbox.observedAttributes[number]} name
|
|
10
|
+
* @param {string | null} oldValue
|
|
11
|
+
* @param {string | null} newValue
|
|
12
|
+
* @returns {void}
|
|
13
|
+
*/
|
|
14
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Check for string is intentional/desired
|
|
16
|
+
if (name === "nomatchesmessage" && newValue !== oldValue) {
|
|
17
|
+
if (newValue) this.#combobox.setAttribute(name, newValue);
|
|
18
|
+
else this.#combobox.removeAttribute(name);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** "On Mount" for Custom Elements @returns {void} */
|
|
23
|
+
connectedCallback() {
|
|
24
|
+
this.setAttribute("role", "listbox");
|
|
25
|
+
this.setAttribute("tabindex", String(-1));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Retrives the `combobox` that this `listbox` belongs to @returns {ComboboxField} */
|
|
29
|
+
get #combobox() {
|
|
30
|
+
return /** @type {ComboboxField} */ (this.previousElementSibling);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default ComboboxListbox;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default ComboboxOption;
|
|
2
|
+
/** @import ComboboxField from "./ComboboxField.js" */
|
|
3
|
+
/** @import ComboboxListbox from "./ComboboxListbox.js" */
|
|
4
|
+
/** @implements {Omit<HTMLOptionElement, "text">} */
|
|
5
|
+
declare class ComboboxOption extends HTMLElement implements Omit<HTMLOptionElement, "text"> {
|
|
6
|
+
static get observedAttributes(): readonly ["value", "selected"];
|
|
7
|
+
/**
|
|
8
|
+
* @param {typeof ComboboxOption.observedAttributes[number]} name
|
|
9
|
+
* @param {string | null} oldValue
|
|
10
|
+
* @param {string | null} newValue
|
|
11
|
+
* @returns {void}
|
|
12
|
+
*/
|
|
13
|
+
attributeChangedCallback(name: (typeof ComboboxOption.observedAttributes)[number], oldValue: string | null, newValue: string | null): void;
|
|
14
|
+
set selected(value: boolean);
|
|
15
|
+
get selected(): boolean;
|
|
16
|
+
/** "On Mount" for Custom Elements @returns {void} */
|
|
17
|
+
connectedCallback(): void;
|
|
18
|
+
/** The `option`'s label */
|
|
19
|
+
get label(): string;
|
|
20
|
+
set value(v: string);
|
|
21
|
+
/** The value of the `option`. Defaults to the `option`'s {@link label}. */
|
|
22
|
+
get value(): string;
|
|
23
|
+
set defaultSelected(value: boolean);
|
|
24
|
+
get defaultSelected(): boolean;
|
|
25
|
+
set disabled(value: boolean);
|
|
26
|
+
get disabled(): boolean;
|
|
27
|
+
/** The position of the option within the list of options that it belongs to. */
|
|
28
|
+
get index(): number;
|
|
29
|
+
/** The `HTMLFormElement` that owns the `combobox` associated with this element */
|
|
30
|
+
get form(): HTMLFormElement | null;
|
|
31
|
+
set filteredOut(value: boolean);
|
|
32
|
+
/**
|
|
33
|
+
* Provides the implementation for how the `option` will be marked/read as filtered out when
|
|
34
|
+
* the `combobox` performs its {@link ComboboxField.getFilteredOptions filtering logic}.
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
get filteredOut(): boolean;
|
|
38
|
+
#private;
|
|
39
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** @import ComboboxField from "./ComboboxField.js" */
|
|
2
|
+
/** @import ComboboxListbox from "./ComboboxListbox.js" */
|
|
3
|
+
|
|
4
|
+
// NOTE: The functionality here is similar to the regular `<select>` + `<option>` spec, with some minor deviations.
|
|
5
|
+
/** @implements {Omit<HTMLOptionElement, "text">} */
|
|
6
|
+
class ComboboxOption extends HTMLElement {
|
|
7
|
+
#mounted = false;
|
|
8
|
+
#selected = false;
|
|
9
|
+
static get observedAttributes() {
|
|
10
|
+
return /** @type {const} */ (["value", "selected"]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {typeof ComboboxOption.observedAttributes[number]} name
|
|
15
|
+
* @param {string | null} oldValue
|
|
16
|
+
* @param {string | null} newValue
|
|
17
|
+
* @returns {void}
|
|
18
|
+
*/
|
|
19
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
20
|
+
if (name === "selected" && (newValue === null) !== (oldValue === null)) {
|
|
21
|
+
this.selected = newValue !== null;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (name === "value" && this.#combobox && newValue !== oldValue) {
|
|
26
|
+
this.id = `${this.#combobox.id}-option-${newValue ?? this.textContent}`;
|
|
27
|
+
return this.#syncWithCombobox();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** "On Mount" for Custom Elements @returns {void} */
|
|
32
|
+
connectedCallback() {
|
|
33
|
+
if (!this.isConnected) return;
|
|
34
|
+
|
|
35
|
+
// Require a Corresponding `listbox` + `combobox`
|
|
36
|
+
if (!this.#listbox) {
|
|
37
|
+
throw new TypeError(`A ${this.constructor.name} must be placed inside a valid \`[role="listbox"]\` element.`);
|
|
38
|
+
}
|
|
39
|
+
if (!this.#combobox) {
|
|
40
|
+
throw new TypeError(`A ${this.constructor.name}'s \`listbox\` must be controlled by a valid \`combobox\``);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!this.#mounted) {
|
|
44
|
+
if (!this.id) this.setAttribute("id", `${this.#combobox.id}-option-${this.value}`);
|
|
45
|
+
this.setAttribute("role", "option");
|
|
46
|
+
this.setAttribute("aria-selected", String(this.selected));
|
|
47
|
+
this.#mounted = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The `option`'s label */
|
|
52
|
+
get label() {
|
|
53
|
+
return this.textContent;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The value of the `option`. Defaults to the `option`'s {@link label}. */
|
|
57
|
+
get value() {
|
|
58
|
+
return this.getAttribute("value") ?? this.label;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
set value(v) {
|
|
62
|
+
this.setAttribute("value", v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get selected() {
|
|
66
|
+
return this.#selected;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
set selected(value) {
|
|
70
|
+
const booleanValue = Boolean(value);
|
|
71
|
+
if (this.#selected === booleanValue) return;
|
|
72
|
+
|
|
73
|
+
this.#selected = booleanValue;
|
|
74
|
+
this.setAttribute("aria-selected", String(this.#selected));
|
|
75
|
+
this.#syncWithCombobox();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get defaultSelected() {
|
|
79
|
+
return this.hasAttribute("selected");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
set defaultSelected(value) {
|
|
83
|
+
this.toggleAttribute("selected", value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get disabled() {
|
|
87
|
+
return this.getAttribute("aria-disabled") === String(true);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
set disabled(value) {
|
|
91
|
+
if (value) this.setAttribute("aria-disabled", String(true));
|
|
92
|
+
else this.removeAttribute("aria-disabled");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// NOTE: This approach might not work anymore if we want to support `group`ed `option`s in the future (unlikely)
|
|
96
|
+
/** The position of the option within the list of options that it belongs to. */
|
|
97
|
+
get index() {
|
|
98
|
+
// NOTE: Defaulting to `0` in lieu of an owning `listbox` mimics what the native `<select>` does.
|
|
99
|
+
return this.#listbox ? Array.prototype.indexOf.call(this.#listbox.children, this) : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The `HTMLFormElement` that owns the `combobox` associated with this element */
|
|
103
|
+
get form() {
|
|
104
|
+
return this.#combobox?.form ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Provides the implementation for how the `option` will be marked/read as filtered out when
|
|
109
|
+
* the `combobox` performs its {@link ComboboxField.getFilteredOptions filtering logic}.
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
get filteredOut() {
|
|
113
|
+
return this.hasAttribute("data-filtered-out");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
set filteredOut(value) {
|
|
117
|
+
this.toggleAttribute("data-filtered-out", value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Retrieves the `listbox` that owns this `option` @returns {ComboboxListbox | null} */
|
|
121
|
+
get #listbox() {
|
|
122
|
+
return /** @type {ComboboxListbox | null} */ (this.closest("[role='listbox']"));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Retrives the `combobox` that this `option` belongs to @returns {ComboboxField | null | undefined} */
|
|
126
|
+
get #combobox() {
|
|
127
|
+
return /** @type {ComboboxField | null | undefined} */ (this.#listbox?.previousElementSibling);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @returns {void} */
|
|
131
|
+
#syncWithCombobox() {
|
|
132
|
+
if (!this.#combobox) return;
|
|
133
|
+
const combobox = this.#combobox;
|
|
134
|
+
|
|
135
|
+
if (this.selected && combobox.value !== this.value) combobox.value = this.value;
|
|
136
|
+
else if (!this.selected && combobox.value === this.value) {
|
|
137
|
+
if (combobox.text.data && combobox.acceptsValue(combobox.text.data)) return;
|
|
138
|
+
if (combobox.acceptsValue("")) return combobox.forceEmptyValue();
|
|
139
|
+
combobox.formResetCallback();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default ComboboxOption;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Combobox
|
|
2
|
+
|
|
3
|
+
A robust, accessible and stylable [`Combobox`](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) [Web Component](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) whose functionality can be extended or customized with ease.
|
|
4
|
+
|
|
5
|
+
## Features and Benefits
|
|
6
|
+
|
|
7
|
+
- **Framework Agnostic**: Because the `combobox` component is just a custom `HTMLElement`, it works seamlessly in all JS Frameworks (and in pure-JS applications if that's what you fancy).
|
|
8
|
+
- **Integrates with Native Web Forms**: This `combobox` integrates with the web's native `<form>` element, meaning that its value will be seen in the [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) and will be automatically [sent to the server](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Sending_and_retrieving_form_data) when the form is submitted -- all without writing a single line of JS.
|
|
9
|
+
- **Works with Various Form Libraries**: The `combobox` component emits standard DOM events like [`input`](https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event) and [`change`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event), enabling it to work naturally with reputable form libraries (e.g., the [`Form Observer`](https://github.com/enthusiastic-js/form-observer), [`Conform`](https://conform.guide/), and [`React Hook Form`](https://react-hook-form.com/)).
|
|
10
|
+
- **Progressive Enhacement**: When used in `Select Enhacing Mode`, the component will fallback to a regular `<select>` element if JS is disabled or unavailable for your users. This means your forms will _always_ be fully usable and accessible.
|
|
11
|
+
- **Highly Customizable**: The `combobox` component is flexible enough to work with whatever CSS you provide, and its functionality can be enhanced or overriden by [extending](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/extends) it.
|
|
12
|
+
- **Performant**: Unlike many other alternatives, the `combobox` component has been cleverly designed to work without complex state management tools or aggressive DOM Tree manipulation. This makes it a fast and memory-efficient solution.
|
|
13
|
+
- **No Dependencies**: The `combobox` component is built on the native web platform instead of extending other frameworks or libraries, guaranteeing your bundle size remains as small as possible.
|
|
14
|
+
|
|
15
|
+
<!-- TODO: Link to article explaining how progressively-enhanced Form Controls _greatly_ simplify frontend code. -->
|
|
16
|
+
|
|
17
|
+
<!-- TODO: Link to example of styling our `combobox` to look like GitHub's or ShadcnUI's. Probably put it alongside an example of another styling approach. -->
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export default SelectEnhancer;
|
|
2
|
+
declare class SelectEnhancer extends HTMLElement {
|
|
3
|
+
/** "On Mount" for Custom Elements @returns {void} */
|
|
4
|
+
connectedCallback(): void;
|
|
5
|
+
set comboboxTag(value: string);
|
|
6
|
+
/**
|
|
7
|
+
* Determines the {@link ComboboxField} element that will be created when the component is mounted in
|
|
8
|
+
* "Select Enhancing Mode" (i.e., with a `<select>` element). Defaults to `combobox-field`.
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
get comboboxTag(): string;
|
|
12
|
+
set listboxTag(value: string);
|
|
13
|
+
/**
|
|
14
|
+
* Determines the {@link ComboboxListbox} element that will be created when the component is mounted in
|
|
15
|
+
* "Select Enhancing Mode" (i.e., with a `<select>` element). Defaults to `combobox-listbox`.
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
get listboxTag(): string;
|
|
19
|
+
set optionTag(value: string);
|
|
20
|
+
/**
|
|
21
|
+
* Determines the {@link ComboboxOption} element(s) that will be created when the component is mounted in
|
|
22
|
+
* "Select Enhancing Mode" (i.e., with a `<select>` element). Defaults to `combobox-option`.
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
get optionTag(): string;
|
|
26
|
+
#private;
|
|
27
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import ComboboxField from "./ComboboxField.js";
|
|
2
|
+
import ComboboxListbox from "./ComboboxListbox.js";
|
|
3
|
+
import ComboboxOption from "./ComboboxOption.js";
|
|
4
|
+
|
|
5
|
+
class SelectEnhancer extends HTMLElement {
|
|
6
|
+
// Internals
|
|
7
|
+
/** @type {ComboboxField | undefined} */ #combobox;
|
|
8
|
+
/** @type {ComboboxListbox | undefined} */ #listbox;
|
|
9
|
+
#mounted = false;
|
|
10
|
+
|
|
11
|
+
/** "On Mount" for Custom Elements @returns {void} */
|
|
12
|
+
connectedCallback() {
|
|
13
|
+
if (!this.isConnected) return;
|
|
14
|
+
|
|
15
|
+
if (!this.#mounted) {
|
|
16
|
+
/* -------------------- Enforce Valid Markup -------------------- */
|
|
17
|
+
/** @type {HTMLSelectElement[]} */ const selects = [];
|
|
18
|
+
/** @type {ComboboxField[]} */ const comboboxes = [];
|
|
19
|
+
/** @type {ComboboxListbox[]} */ const listboxes = [];
|
|
20
|
+
|
|
21
|
+
for (let child = this.lastElementChild; child; child = child.previousElementSibling) {
|
|
22
|
+
customElements.upgrade(child);
|
|
23
|
+
if (child instanceof HTMLSelectElement) selects.push(child);
|
|
24
|
+
else if (child instanceof ComboboxField) comboboxes.push(child);
|
|
25
|
+
else if (child instanceof ComboboxListbox) listboxes.push(child);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (
|
|
29
|
+
selects.length > 1 ||
|
|
30
|
+
comboboxes.length > 1 ||
|
|
31
|
+
listboxes.length > 1 ||
|
|
32
|
+
comboboxes.length !== listboxes.length ||
|
|
33
|
+
selects.length === comboboxes.length
|
|
34
|
+
) {
|
|
35
|
+
const line1 = `${this.constructor.name} must contain one (and only one) <select> element.`;
|
|
36
|
+
const line2 = `Alternatively, you may supply one ${ComboboxField.name} and one ${ComboboxListbox.name}.`;
|
|
37
|
+
throw new TypeError(`${line1}\n${line2}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const select = /** @type {HTMLSelectElement | undefined} */ (selects[0]);
|
|
41
|
+
if (select) {
|
|
42
|
+
this.#combobox = /** @type {ComboboxField} */ (document.createElement(this.comboboxTag));
|
|
43
|
+
if (!(this.#combobox instanceof ComboboxField)) {
|
|
44
|
+
throw new TypeError(`<${this.comboboxTag}> is not registered as a \`${ComboboxField.name}\``);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.#listbox = /** @type {ComboboxListbox} */ (document.createElement(this.listboxTag));
|
|
48
|
+
if (!(this.#listbox instanceof ComboboxListbox)) {
|
|
49
|
+
throw new TypeError(`<${this.listboxTag}> is not registered as a \`${ComboboxListbox.name}\``);
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
[this.#combobox] = comboboxes;
|
|
53
|
+
[this.#listbox] = listboxes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* -------------------- Setup Elements -------------------- */
|
|
57
|
+
// Give the `combobox` and the `listbox` a common parent for DOM Manipulation if needed.
|
|
58
|
+
if (!this.#combobox.isConnected) document.createDocumentFragment().append(this.#combobox, this.#listbox);
|
|
59
|
+
|
|
60
|
+
// Root Element
|
|
61
|
+
this.setAttribute("role", "none");
|
|
62
|
+
|
|
63
|
+
// Combobox
|
|
64
|
+
if (select) {
|
|
65
|
+
const attributeNames = select.getAttributeNames();
|
|
66
|
+
for (let i = 0; i < attributeNames.length; i++) {
|
|
67
|
+
const attrName = attributeNames[i];
|
|
68
|
+
this.#combobox.setAttribute(attrName, /** @type {string} */ (select.getAttribute(attrName)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Listbox
|
|
73
|
+
// TODO: Maybe use `crypto.getRandomValues()` instead?
|
|
74
|
+
this.#combobox.id ||= Math.random().toString(36).slice(2);
|
|
75
|
+
const listboxId = `${this.#combobox.id}-listbox`;
|
|
76
|
+
this.#listbox.setAttribute("id", listboxId);
|
|
77
|
+
this.#listbox.setAttribute("role", "listbox");
|
|
78
|
+
this.#combobox.setAttribute("aria-controls", listboxId);
|
|
79
|
+
|
|
80
|
+
// Listbox Options
|
|
81
|
+
// `SelectEnhancer` was initialized with pre-supplied `combobox` and `listbox` Web Components
|
|
82
|
+
if (!select) {
|
|
83
|
+
let defaultOptionExists = false;
|
|
84
|
+
/** @type {Element[]} */ const invalidChildren = [];
|
|
85
|
+
/** @type {ComboboxOption | undefined | null} */ let selectedOption;
|
|
86
|
+
for (let child = this.#listbox.lastElementChild; child; child = child.previousElementSibling) {
|
|
87
|
+
if (!(child instanceof ComboboxOption)) invalidChildren.push(child);
|
|
88
|
+
else {
|
|
89
|
+
if (selectedOption) child.selected = false; // NOTE: Would not work if we want to support `multiple` mode
|
|
90
|
+
if (child.defaultSelected) defaultOptionExists = true;
|
|
91
|
+
if (child.selected && !selectedOption) selectedOption = child;
|
|
92
|
+
child.id = `${this.#combobox.id}-option-${child.value}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
invalidChildren.forEach((c) => c.remove());
|
|
97
|
+
selectedOption ??= /** @type {ComboboxOption | null} */ (this.#listbox.firstElementChild);
|
|
98
|
+
if (selectedOption) this.#combobox.value = selectedOption.value;
|
|
99
|
+
|
|
100
|
+
// NOTE: Might override an `option` that is `selected` but not `defaultSelected`. However, this behavior matches
|
|
101
|
+
// `Select Enhancing Mode`, and it can be resolved by explicitly setting `defaultSelected`, which is encouraged.
|
|
102
|
+
if (!defaultOptionExists && this.#combobox.acceptsValue("")) this.#combobox.forceEmptyValue();
|
|
103
|
+
}
|
|
104
|
+
// `SelectEnhancer` is meant to enhance/replace a single `<select>` instead
|
|
105
|
+
else {
|
|
106
|
+
let defaultOptionExists = false;
|
|
107
|
+
/** @type {ComboboxOption | undefined} */ let selectedOption;
|
|
108
|
+
const { optionTag } = this;
|
|
109
|
+
const OptionConstructor = customElements.get(optionTag);
|
|
110
|
+
if (OptionConstructor !== ComboboxOption && !(OptionConstructor?.prototype instanceof ComboboxOption)) {
|
|
111
|
+
throw new TypeError(`<${optionTag}> is not registered as a \`${ComboboxOption.name}\``);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < select.options.length; i++) {
|
|
115
|
+
const option = select.options[i];
|
|
116
|
+
const comboboxOption = /** @type {ComboboxOption} */ (document.createElement(optionTag));
|
|
117
|
+
this.#listbox.appendChild(comboboxOption);
|
|
118
|
+
|
|
119
|
+
comboboxOption.textContent = option.label;
|
|
120
|
+
comboboxOption.defaultSelected = option.defaultSelected;
|
|
121
|
+
if (comboboxOption.defaultSelected) defaultOptionExists = true;
|
|
122
|
+
if (option.hasAttribute("disabled")) comboboxOption.disabled = true;
|
|
123
|
+
if (option.hasAttribute("value")) comboboxOption.setAttribute("value", option.value);
|
|
124
|
+
|
|
125
|
+
// NOTE: `value` MUST be set BEFORE `id`, which is set BEFORE `selected`. (Due to A11y and Value Selection Logic.)
|
|
126
|
+
comboboxOption.setAttribute("id", `${this.#combobox.id}-option-${comboboxOption.value}`);
|
|
127
|
+
comboboxOption.selected = option.selected;
|
|
128
|
+
if (comboboxOption.selected) selectedOption = comboboxOption;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// `selectedOption` is only guaranteed to exist if `<select>` had at least one `<option>`
|
|
132
|
+
if (selectedOption) this.#combobox.value = selectedOption.value;
|
|
133
|
+
|
|
134
|
+
// Enable `clearable`/`anyvalue` `combobox`es without a default value to start out as an empty field
|
|
135
|
+
if (!defaultOptionExists && this.#combobox.acceptsValue("")) this.#combobox.forceEmptyValue();
|
|
136
|
+
|
|
137
|
+
// Render Elements
|
|
138
|
+
select.replaceWith(this.#combobox, this.#listbox);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.#mounted = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Determines the {@link ComboboxField} element that will be created when the component is mounted in
|
|
147
|
+
* "Select Enhancing Mode" (i.e., with a `<select>` element). Defaults to `combobox-field`.
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
get comboboxTag() {
|
|
151
|
+
return this.getAttribute("comboboxtag") ?? "combobox-field";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
set comboboxTag(value) {
|
|
155
|
+
this.setAttribute("comboboxtag", value);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Determines the {@link ComboboxListbox} element that will be created when the component is mounted in
|
|
160
|
+
* "Select Enhancing Mode" (i.e., with a `<select>` element). Defaults to `combobox-listbox`.
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
get listboxTag() {
|
|
164
|
+
return this.getAttribute("listboxtag") ?? "combobox-listbox";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
set listboxTag(value) {
|
|
168
|
+
this.setAttribute("listboxtag", value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Determines the {@link ComboboxOption} element(s) that will be created when the component is mounted in
|
|
173
|
+
* "Select Enhancing Mode" (i.e., with a `<select>` element). Defaults to `combobox-option`.
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
get optionTag() {
|
|
177
|
+
return this.getAttribute("optiontag") ?? "combobox-option";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
set optionTag(value) {
|
|
181
|
+
this.setAttribute("optiontag", value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default SelectEnhancer;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ComboboxField, ComboboxListbox, ComboboxOption, SelectEnhancer } from "../index.js";
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
interface HTMLElementTagNameMap {
|
|
5
|
+
"select-enhancer": SelectEnhancer;
|
|
6
|
+
"combobox-field": ComboboxField;
|
|
7
|
+
"combobox-listbox": ComboboxListbox;
|
|
8
|
+
"combobox-option": ComboboxOption;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ComboboxField, ComboboxListbox, ComboboxOption, SelectEnhancer } from "../index.js";
|
|
2
|
+
|
|
3
|
+
declare module "preact" {
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace -- Necessary for type declaration merging
|
|
5
|
+
namespace JSX {
|
|
6
|
+
interface IntrinsicElements {
|
|
7
|
+
"select-enhancer": SelectEnhancerHTMLAttributes<SelectEnhancer>;
|
|
8
|
+
"combobox-field": ComboboxFieldHTMLAttributes<ComboboxField>;
|
|
9
|
+
"combobox-listbox": HTMLAttributes<ComboboxListbox>;
|
|
10
|
+
"combobox-option": ComboboxOptionHTMLAttributes<ComboboxOption>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SelectEnhancerHTMLAttributes<T extends EventTarget = SelectEnhancer> extends HTMLAttributes<T> {
|
|
14
|
+
comboboxtag?: Signalish<SelectEnhancer["comboboxTag"] | undefined>;
|
|
15
|
+
listboxtag?: Signalish<SelectEnhancer["listboxTag"] | undefined>;
|
|
16
|
+
optiontag?: Signalish<SelectEnhancer["optionTag"] | undefined>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ComboboxFieldHTMLAttributes<T extends EventTarget = ComboboxField> extends HTMLAttributes<T> {
|
|
20
|
+
disabled?: Signalish<ComboboxField["disabled"] | undefined>;
|
|
21
|
+
filter?: Signalish<ComboboxField["filter"] | undefined>;
|
|
22
|
+
filtermethod?: Signalish<ComboboxField["filterMethod"] | undefined>;
|
|
23
|
+
form?: Signalish<string | undefined>;
|
|
24
|
+
name?: Signalish<ComboboxField["name"] | undefined>;
|
|
25
|
+
nomatchesmessage?: Signalish<ComboboxField["noMatchesMessage"] | undefined>;
|
|
26
|
+
required?: Signalish<ComboboxField["required"] | undefined>;
|
|
27
|
+
valueis?: Signalish<ComboboxField["valueIs"] | undefined>;
|
|
28
|
+
valuemissingerror?: Signalish<ComboboxField["valueMissingError"] | undefined>;
|
|
29
|
+
onfilterchange?: GenericEventHandler<T> | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- This is required to support ComboboxField props
|
|
33
|
+
interface SelectHTMLAttributes<T extends EventTarget = HTMLSelectElement>
|
|
34
|
+
extends Omit<ComboboxFieldHTMLAttributes<T>, "onfilterchange"> {}
|
|
35
|
+
|
|
36
|
+
interface ComboboxOptionHTMLAttributes<T extends EventTarget = ComboboxOption> extends HTMLAttributes<T> {
|
|
37
|
+
defaultSelected?: Signalish<ComboboxOption["defaultSelected"] | undefined>;
|
|
38
|
+
disabled?: Signalish<ComboboxOption["disabled"] | undefined>;
|
|
39
|
+
selected?: Signalish<ComboboxOption["selected"] | undefined>;
|
|
40
|
+
value?: Signalish<ComboboxOption["value"] | undefined>;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ComboboxField, ComboboxListbox, ComboboxOption, SelectEnhancer } from "../index.js";
|
|
2
|
+
|
|
3
|
+
declare module "react" {
|
|
4
|
+
interface SelectEnhancerHTMLAttributes<T> extends HTMLAttributes<T> {
|
|
5
|
+
comboboxtag?: SelectEnhancer["comboboxTag"];
|
|
6
|
+
listboxtag?: SelectEnhancer["listboxTag"];
|
|
7
|
+
optiontag?: SelectEnhancer["optionTag"];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ComboboxFieldHTMLAttributes<T> extends HTMLAttributes<T> {
|
|
11
|
+
disabled?: ComboboxField["disabled"];
|
|
12
|
+
filter?: ComboboxField["filter"];
|
|
13
|
+
filtermethod?: ComboboxField["filterMethod"];
|
|
14
|
+
form?: string; // WARNING: React does not yet support the `form` attribute for Custom Elements
|
|
15
|
+
name?: ComboboxField["name"];
|
|
16
|
+
nomatchesmessage?: ComboboxField["noMatchesMessage"];
|
|
17
|
+
required?: ComboboxField["required"];
|
|
18
|
+
valueis?: ComboboxField["valueIs"];
|
|
19
|
+
valuemissingerror?: ComboboxField["valueMissingError"];
|
|
20
|
+
|
|
21
|
+
onfilterchange?: ReactEventHandler<T>;
|
|
22
|
+
onfilterchangeCapture?: ReactEventHandler<T>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SelectHTMLAttributes<T>
|
|
26
|
+
extends Omit<ComboboxFieldHTMLAttributes<T>, "filter" | "onfilterchange" | "onfilterchangeCapture"> {
|
|
27
|
+
filter?: "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ComboboxOptionHTMLAttributes<T> extends HTMLAttributes<T> {
|
|
31
|
+
defaultSelected?: ComboboxOption["defaultSelected"];
|
|
32
|
+
disabled?: ComboboxOption["disabled"];
|
|
33
|
+
selected?: ComboboxOption["selected"];
|
|
34
|
+
value?: ComboboxOption["value"];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace -- Necessary for type declaration merging
|
|
38
|
+
namespace JSX {
|
|
39
|
+
interface IntrinsicElements {
|
|
40
|
+
"select-enhancer": React.DetailedHTMLProps<React.SelectEnhancerHTMLAttributes<SelectEnhancer>, SelectEnhancer>;
|
|
41
|
+
"combobox-field": React.DetailedHTMLProps<React.ComboboxFieldHTMLAttributes<ComboboxField>, ComboboxField>;
|
|
42
|
+
"combobox-listbox": React.DetailedHTMLProps<React.HTMLAttributes<ComboboxListbox>, ComboboxListbox>;
|
|
43
|
+
"combobox-option": React.DetailedHTMLProps<React.ComboboxOptionHTMLAttributes<ComboboxOption>, ComboboxOption>;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* NOTE: React handles native attributes inconsistently between Custom Elements and native `HTMLElement`s.
|
|
50
|
+
* For example, applying `form` to a native form control will alter the corresponding attribute. However,
|
|
51
|
+
* applying `form` to a Custom Form Control that exposes a `form` getter will cause React to throw a runtime
|
|
52
|
+
* error. The React team will need to figure out the best way to address this problem. See:
|
|
53
|
+
* https://github.com/facebook/react/issues/34663.
|
|
54
|
+
*/
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SelectEnhancer, ComboboxField, ComboboxListbox, ComboboxOption } from "../index.js";
|
|
2
|
+
|
|
3
|
+
declare module "solid-js" {
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace -- Necessary for type declaration merging
|
|
5
|
+
namespace JSX {
|
|
6
|
+
interface HTMLElementTags {
|
|
7
|
+
"select-enhancer": SelectEnhancerHTMLAttributes<SelectEnhancer>;
|
|
8
|
+
"combobox-field": ComboboxFieldHTMLAttributes<ComboboxField>;
|
|
9
|
+
"combobox-listbox": HTMLAttributes<ComboboxListbox>;
|
|
10
|
+
"combobox-option": ComboboxOptionHTMLAttributes<ComboboxOption>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SelectEnhancerHTMLAttributes<T> extends HTMLAttributes<T> {
|
|
14
|
+
comboboxtag?: SelectEnhancer["comboboxTag"];
|
|
15
|
+
listboxtag?: SelectEnhancer["listboxTag"];
|
|
16
|
+
optiontag?: SelectEnhancer["optionTag"];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ComboboxFieldHTMLAttributes<T> extends HTMLAttributes<T> {
|
|
20
|
+
disabled?: ComboboxField["disabled"];
|
|
21
|
+
filter?: ComboboxField["filter"];
|
|
22
|
+
filtermethod?: ComboboxField["filterMethod"];
|
|
23
|
+
form?: string;
|
|
24
|
+
name?: ComboboxField["name"];
|
|
25
|
+
nomatchesmessage?: ComboboxField["noMatchesMessage"];
|
|
26
|
+
required?: ComboboxField["required"];
|
|
27
|
+
valueis?: ComboboxField["valueIs"];
|
|
28
|
+
valuemissingerror?: ComboboxField["valueMissingError"];
|
|
29
|
+
|
|
30
|
+
onFilterchange?: EventHandlerUnion<T, Event>;
|
|
31
|
+
onfilterchange?: EventHandlerUnion<T, Event>;
|
|
32
|
+
"on:filterchange"?: EventHandlerWithOptionsUnion<T, Event>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- This is required to support ComboboxField props
|
|
36
|
+
interface SelectHTMLAttributes<T>
|
|
37
|
+
extends Omit<ComboboxFieldHTMLAttributes<T>, "onFilterchange" | "onfilterchange" | "on:filterchange"> {}
|
|
38
|
+
|
|
39
|
+
interface ComboboxOptionHTMLAttributes<T> extends HTMLAttributes<T> {
|
|
40
|
+
disabled?: ComboboxOption["disabled"];
|
|
41
|
+
selected?: ComboboxOption["selected"];
|
|
42
|
+
value?: ComboboxOption["value"];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|