@itenthusiasm/custom-elements 0.0.3 → 0.9.0
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/CheckboxGroup/CheckboxGroup.d.ts +86 -0
- package/CheckboxGroup/CheckboxGroup.js +389 -0
- package/CheckboxGroup/README.md +546 -0
- package/CheckboxGroup/index.d.ts +2 -0
- package/CheckboxGroup/index.js +2 -0
- package/CheckboxGroup/types/dom.d.ts +7 -0
- package/CheckboxGroup/types/preact.d.ts +23 -0
- package/CheckboxGroup/types/react.d.ts +23 -0
- package/CheckboxGroup/types/solid.d.ts +29 -0
- package/CheckboxGroup/types/svelte.d.ts +20 -0
- package/CheckboxGroup/types/vue.d.ts +40 -0
- package/Combobox/Combobox.css +8 -7
- package/Combobox/ComboboxField.d.ts +8 -19
- package/Combobox/ComboboxField.js +127 -89
- package/Combobox/README.md +262 -10
- package/Combobox/types/preact.d.ts +1 -1
- package/Combobox/types/react.d.ts +1 -1
- package/Combobox/types/solid.d.ts +13 -2
- package/Combobox/types/svelte.d.ts +2 -2
- package/Combobox/types/vue.d.ts +1 -1
- package/README.md +46 -1
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +6 -2
- package/types/dom.d.ts +1 -0
- package/types/helpers.d.ts +12 -0
- package/types/preact.d.ts +1 -0
- package/types/react.d.ts +1 -0
- package/types/solid.d.ts +1 -0
- package/types/svelte.d.ts +1 -0
- package/types/vue.d.ts +1 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CheckboxGroup } from "../index.js";
|
|
2
|
+
|
|
3
|
+
declare module "solid-js" {
|
|
4
|
+
namespace JSX {
|
|
5
|
+
interface HTMLElementTags {
|
|
6
|
+
"checkbox-group": CheckboxGroupHTMLAttributes<CheckboxGroup>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface CheckboxGroupHTMLAttributes<T> extends HTMLAttributes<T> {
|
|
10
|
+
value?: CheckboxGroup["value"];
|
|
11
|
+
name?: CheckboxGroup["name"];
|
|
12
|
+
disabled?: CheckboxGroup["disabled"];
|
|
13
|
+
required?: CheckboxGroup["required"];
|
|
14
|
+
min?: CheckboxGroup["min"] | number;
|
|
15
|
+
max?: CheckboxGroup["max"] | number;
|
|
16
|
+
manual?: CheckboxGroup["manual"];
|
|
17
|
+
valuemissingerror?: CheckboxGroup["valueMissingError"];
|
|
18
|
+
"attr:valuemissingerror"?: CheckboxGroup["valueMissingError"];
|
|
19
|
+
rangeunderflowerror?: CheckboxGroup["rangeUnderflowError"];
|
|
20
|
+
"attr:rangeunderflowerror"?: CheckboxGroup["rangeUnderflowError"];
|
|
21
|
+
rangeoverflowerror?: CheckboxGroup["rangeOverflowError"];
|
|
22
|
+
"attr:rangeoverflowerror"?: CheckboxGroup["rangeOverflowError"];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ExplicitBoolAttributes {
|
|
26
|
+
checked?: boolean;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CheckboxGroup } from "../index.js";
|
|
2
|
+
|
|
3
|
+
declare module "svelte/elements" {
|
|
4
|
+
interface SvelteHTMLElements {
|
|
5
|
+
"checkbox-group": HTMLCheckboxGroupAttributes;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface HTMLCheckboxGroupAttributes extends HTMLAttributes<CheckboxGroup> {
|
|
9
|
+
value?: CheckboxGroup["value"] | null;
|
|
10
|
+
name?: CheckboxGroup["name"] | null;
|
|
11
|
+
disabled?: CheckboxGroup["disabled"] | null;
|
|
12
|
+
required?: CheckboxGroup["required"] | null;
|
|
13
|
+
min?: CheckboxGroup["min"] | number | null;
|
|
14
|
+
max?: CheckboxGroup["max"] | number | null;
|
|
15
|
+
manual?: CheckboxGroup["manual"] | null;
|
|
16
|
+
valuemissingerror?: CheckboxGroup["valueMissingError"] | null;
|
|
17
|
+
rangeunderflowerror?: CheckboxGroup["rangeUnderflowError"] | null;
|
|
18
|
+
rangeoverflowerror?: CheckboxGroup["rangeOverflowError"] | null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HTMLAttributes, PublicProps, EmitFn } from "vue";
|
|
2
|
+
import type { CheckboxGroup } from "../index.js";
|
|
3
|
+
|
|
4
|
+
declare module "vue" {
|
|
5
|
+
// Helper Types
|
|
6
|
+
type Booleanish = boolean | "true" | "false";
|
|
7
|
+
type VueEmitMap<T extends GlobalEventHandlersEventMap> = EmitFn<{ [K in keyof T]: (event: T[K]) => void }>;
|
|
8
|
+
interface VueGlobalHTMLAttributes extends HTMLAttributes, Omit<PublicProps, "class" | "style"> {}
|
|
9
|
+
|
|
10
|
+
/* -------------------- Register Elements -------------------- */
|
|
11
|
+
interface GlobalComponents {
|
|
12
|
+
"checkbox-group": new () => CheckboxGroupVueSFCType;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface IntrinsicElementAttributes {
|
|
16
|
+
"checkbox-group": CheckboxGroupHTMLAttributes;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* -------------------- Checkbox Group -------------------- */
|
|
20
|
+
interface CheckboxGroupHTMLAttributes extends VueGlobalHTMLAttributes {
|
|
21
|
+
value?: CheckboxGroup["value"];
|
|
22
|
+
name?: CheckboxGroup["name"];
|
|
23
|
+
disabled?: CheckboxGroup["disabled"];
|
|
24
|
+
required?: CheckboxGroup["required"];
|
|
25
|
+
min?: CheckboxGroup["min"] | number;
|
|
26
|
+
max?: CheckboxGroup["max"] | number;
|
|
27
|
+
manual?: CheckboxGroup["manual"];
|
|
28
|
+
valuemissingerror?: CheckboxGroup["valueMissingError"];
|
|
29
|
+
rangeunderflowerror?: CheckboxGroup["rangeUnderflowError"];
|
|
30
|
+
rangeoverflowerror?: CheckboxGroup["rangeOverflowError"];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CheckboxGroupVueSFCType extends CheckboxGroup {
|
|
34
|
+
/** @deprecated Only for use by Vue's templating language */
|
|
35
|
+
$props: CheckboxGroupHTMLAttributes;
|
|
36
|
+
|
|
37
|
+
/** @deprecated Only for use by Vue's templating language */
|
|
38
|
+
$emit: VueEmitMap<HTMLElementEventMap>;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/Combobox/Combobox.css
CHANGED
|
@@ -4,7 +4,7 @@ select-enhancer {
|
|
|
4
4
|
position: relative;
|
|
5
5
|
display: inline-block;
|
|
6
6
|
box-sizing: border-box;
|
|
7
|
-
width:
|
|
7
|
+
width: min(100%, 200px);
|
|
8
8
|
height: inherit;
|
|
9
9
|
|
|
10
10
|
@media only screen and (min-width: 600px) {
|
|
@@ -26,7 +26,7 @@ select-enhancer {
|
|
|
26
26
|
|
|
27
27
|
cursor: pointer;
|
|
28
28
|
white-space: pre;
|
|
29
|
-
text-align:
|
|
29
|
+
text-align: left;
|
|
30
30
|
font-size: inherit;
|
|
31
31
|
font-family: inherit;
|
|
32
32
|
color: currentcolor;
|
|
@@ -64,15 +64,16 @@ select-enhancer {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
& > [role="option"],
|
|
67
|
-
&:where([role="combobox"][data-bad-filter] + [role="listbox"])::
|
|
67
|
+
&:where([role="combobox"][data-bad-filter] + [role="listbox"])::before {
|
|
68
68
|
display: block;
|
|
69
69
|
box-sizing: border-box;
|
|
70
70
|
height: var(--option-height);
|
|
71
71
|
padding: var(--option-padding);
|
|
72
|
+
align-content: center;
|
|
72
73
|
cursor: pointer;
|
|
73
74
|
|
|
74
75
|
&[data-active="true"]:not([aria-selected="true"]) {
|
|
75
|
-
background-color: #bddaff; /* `background-color` for `selected` items, brightened by 70% */
|
|
76
|
+
background-color: #bddaff; /* This color is the `background-color` for `selected` items, brightened by 70% */
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
&[aria-selected="true"] {
|
|
@@ -85,13 +86,13 @@ select-enhancer {
|
|
|
85
86
|
visibility: hidden; /* Needed to hide filtered-out `option`s in Safari + VoiceOver (Bug in Browser) */
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
/* TODO: Add clearer `disabled` styles. */
|
|
89
|
+
/* TODO: Add clearer/better `disabled` styles. */
|
|
89
90
|
&[aria-disabled="true"] {
|
|
90
|
-
cursor:
|
|
91
|
+
cursor: not-allowed;
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
&:where([role="combobox"][data-bad-filter] + [role="listbox"])::
|
|
95
|
+
&:where([role="combobox"][data-bad-filter] + [role="listbox"])::before {
|
|
95
96
|
content: attr(nomatchesmessage, "No options found") / "";
|
|
96
97
|
cursor: auto;
|
|
97
98
|
}
|
|
@@ -1,14 +1,4 @@
|
|
|
1
1
|
export default ComboboxField;
|
|
2
|
-
export type ExposedInternals = Pick<ElementInternals, "labels" | "form" | "validity" | "validationMessage" | "willValidate" | "checkValidity" | "reportValidity">;
|
|
3
|
-
export type FieldPropertiesAndMethods = Pick<HTMLInputElement, "name" | "required" | "disabled" | "setCustomValidity">;
|
|
4
|
-
/**
|
|
5
|
-
* @typedef {Pick<ElementInternals,
|
|
6
|
-
"labels" | "form" | "validity" | "validationMessage" | "willValidate" | "checkValidity" | "reportValidity"
|
|
7
|
-
>} ExposedInternals
|
|
8
|
-
*/
|
|
9
|
-
/**
|
|
10
|
-
* @typedef {Pick<HTMLInputElement, "name" | "required" | "disabled" | "setCustomValidity">} FieldPropertiesAndMethods
|
|
11
|
-
*/
|
|
12
2
|
/** @implements {ExposedInternals} @implements {FieldPropertiesAndMethods} */
|
|
13
3
|
declare class ComboboxField extends HTMLElement implements ExposedInternals, FieldPropertiesAndMethods {
|
|
14
4
|
/** @returns {true} */
|
|
@@ -71,8 +61,8 @@ declare class ComboboxField extends HTMLElement implements ExposedInternals, Fie
|
|
|
71
61
|
* @returns {void}
|
|
72
62
|
*/
|
|
73
63
|
attributeChangedCallback(name: (typeof ComboboxField.observedAttributes)[number], oldValue: string | null, newValue: string | null): void;
|
|
74
|
-
/** @param {string}
|
|
75
|
-
set value(
|
|
64
|
+
/** @param {string} V */
|
|
65
|
+
set value(V: string);
|
|
76
66
|
/** Sets or retrieves the `value` of the `combobox` @returns {string | null} */
|
|
77
67
|
get value(): string | null;
|
|
78
68
|
/** "On Mount" for Custom Elements @returns {void} */
|
|
@@ -157,7 +147,7 @@ declare class ComboboxField extends HTMLElement implements ExposedInternals, Fie
|
|
|
157
147
|
set filter(value: boolean);
|
|
158
148
|
/** Activates a textbox that can be used to filter the list of `combobox` `option`s. @returns {boolean} */
|
|
159
149
|
get filter(): boolean;
|
|
160
|
-
set filterMethod(value: Extract<number | typeof Symbol.iterator | "
|
|
150
|
+
set filterMethod(value: Extract<number | "normalize" | typeof Symbol.iterator | "length" | "toString" | "concat" | "slice" | "indexOf" | "lastIndexOf" | "includes" | "at" | "link" | "search" | "small" | "sub" | "sup" | "big" | "blink" | "strike" | "charAt" | "charCodeAt" | "localeCompare" | "match" | "replace" | "split" | "substring" | "toLowerCase" | "toLocaleLowerCase" | "toUpperCase" | "toLocaleUpperCase" | "trim" | "substr" | "valueOf" | "codePointAt" | "endsWith" | "repeat" | "startsWith" | "anchor" | "bold" | "fixed" | "fontcolor" | "fontsize" | "italics" | "padStart" | "padEnd" | "trimEnd" | "trimStart" | "trimLeft" | "trimRight" | "matchAll" | "replaceAll" | "isWellFormed" | "toWellFormed", "startsWith" | "includes">);
|
|
161
151
|
/**
|
|
162
152
|
* Determines the method used to filter the `option`s as the user types.
|
|
163
153
|
* - `startsWith`: {@link String.startsWith} will be used to filter the `option`s.
|
|
@@ -167,7 +157,7 @@ declare class ComboboxField extends HTMLElement implements ExposedInternals, Fie
|
|
|
167
157
|
*
|
|
168
158
|
* @returns {Extract<keyof String, "startsWith" | "includes">}
|
|
169
159
|
*/
|
|
170
|
-
get filterMethod(): Extract<number | typeof Symbol.iterator | "
|
|
160
|
+
get filterMethod(): Extract<number | "normalize" | typeof Symbol.iterator | "length" | "toString" | "concat" | "slice" | "indexOf" | "lastIndexOf" | "includes" | "at" | "link" | "search" | "small" | "sub" | "sup" | "big" | "blink" | "strike" | "charAt" | "charCodeAt" | "localeCompare" | "match" | "replace" | "split" | "substring" | "toLowerCase" | "toLocaleLowerCase" | "toUpperCase" | "toLocaleUpperCase" | "trim" | "substr" | "valueOf" | "codePointAt" | "endsWith" | "repeat" | "startsWith" | "anchor" | "bold" | "fixed" | "fontcolor" | "fontsize" | "italics" | "padStart" | "padEnd" | "trimEnd" | "trimStart" | "trimLeft" | "trimRight" | "matchAll" | "replaceAll" | "isWellFormed" | "toWellFormed", "startsWith" | "includes">;
|
|
171
161
|
set valueIs(value: "unclearable" | "clearable" | "anyvalue");
|
|
172
162
|
/**
|
|
173
163
|
* Indicates how a `combobox`'s value will behave.
|
|
@@ -178,10 +168,7 @@ declare class ComboboxField extends HTMLElement implements ExposedInternals, Fie
|
|
|
178
168
|
* - `anyvalue`: The field's `value` can be any string, and it will automatically be set to
|
|
179
169
|
* whatever value the user types. (Requires enabling `filter` mode.)
|
|
180
170
|
*
|
|
181
|
-
*
|
|
182
|
-
* TODO: Link to Documentation for More Details (like TS does for MDN). The deeper details of the behavior
|
|
183
|
-
* are too sophisticated to place them all in a JSDoc, which should be [sufficiently] clear and succinct
|
|
184
|
-
* -->
|
|
171
|
+
* [API Reference](https://github.com/ITenthusiasm/custom-elements/blob/main/src/Combobox/docs/combobox-field.md#attributes-valueis)
|
|
185
172
|
*
|
|
186
173
|
* @returns {"unclearable" | "clearable" | "anyvalue"}
|
|
187
174
|
*/
|
|
@@ -196,7 +183,7 @@ declare class ComboboxField extends HTMLElement implements ExposedInternals, Fie
|
|
|
196
183
|
* Returns the `option` whose `label` matches the user's most recent filter input (if one exists).
|
|
197
184
|
*
|
|
198
185
|
* Value will be `null` if:
|
|
199
|
-
* - The user's filter didn't match any `option`s
|
|
186
|
+
* - The user's filter didn't match any (enabled) `option`s
|
|
200
187
|
* - The `combobox`'s text content was altered by a `value` change
|
|
201
188
|
* - The `combobox` was just recently expanded
|
|
202
189
|
* @returns {ComboboxOption | null}
|
|
@@ -235,6 +222,8 @@ declare class ComboboxField extends HTMLElement implements ExposedInternals, Fie
|
|
|
235
222
|
/** @private @type {boolean} */ private [editingKey];
|
|
236
223
|
#private;
|
|
237
224
|
}
|
|
225
|
+
import type { ExposedInternals } from "../types/helpers.js";
|
|
226
|
+
import type { FieldPropertiesAndMethods } from "../types/helpers.js";
|
|
238
227
|
import ComboboxOption from "./ComboboxOption.js";
|
|
239
228
|
import type { ListboxWithChildren } from "./types/helpers.js";
|
|
240
229
|
/** Internally used to retrieve the value that the `combobox` had when it was focused. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** @import {ListboxWithChildren} from "./types/helpers.js" */
|
|
2
|
+
/** @import {ExposedInternals, FieldPropertiesAndMethods} from "../types/helpers.js" */
|
|
2
3
|
import { setAttributeFor } from "../utils/dom.js";
|
|
3
4
|
import ComboboxOption from "./ComboboxOption.js";
|
|
4
5
|
import ComboboxListbox from "./ComboboxListbox.js";
|
|
@@ -21,20 +22,6 @@ const attrs = Object.freeze({
|
|
|
21
22
|
"aria-expanded": "aria-expanded",
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
* @typedef {Pick<ElementInternals,
|
|
26
|
-
"labels" | "form" | "validity" | "validationMessage" | "willValidate" | "checkValidity" | "reportValidity"
|
|
27
|
-
>} ExposedInternals
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* @typedef {Pick<HTMLInputElement, "name" | "required" | "disabled" | "setCustomValidity">} FieldPropertiesAndMethods
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
/*
|
|
35
|
-
* TODO: Some of our functionality requires (or recommends) CSS to be properly implemented (e.g., hiding `listbox`,
|
|
36
|
-
* properly showing white spaces, etc.). We should probably explain all such things to developers.
|
|
37
|
-
*/
|
|
38
25
|
/** @implements {ExposedInternals} @implements {FieldPropertiesAndMethods} */
|
|
39
26
|
class ComboboxField extends HTMLElement {
|
|
40
27
|
/* ------------------------------ Custom Element Settings ------------------------------ */
|
|
@@ -95,7 +82,7 @@ class ComboboxField extends HTMLElement {
|
|
|
95
82
|
* @returns {void}
|
|
96
83
|
*/
|
|
97
84
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
98
|
-
if (name === "id" && newValue !== oldValue) {
|
|
85
|
+
if (name === "id" && this.#mounted && newValue !== oldValue) {
|
|
99
86
|
this.listbox.id = `${this.id}-listbox`;
|
|
100
87
|
for (let option = this.listbox.firstElementChild; option; option = /** @type {any} */ (option.nextElementSibling))
|
|
101
88
|
option.id = `${this.id}-option-${option.value}`;
|
|
@@ -122,16 +109,19 @@ class ComboboxField extends HTMLElement {
|
|
|
122
109
|
oldValue === "anyvalue" || oldValue === "unclearable" ? oldValue : "clearable";
|
|
123
110
|
if (trueNewValue === trueOldValue && !filterModeIsBeingDisabled) return;
|
|
124
111
|
|
|
112
|
+
const hasOptions = this.listbox.children.length !== 0;
|
|
113
|
+
|
|
125
114
|
// `anyvalue` activated
|
|
126
115
|
if (trueNewValue === "anyvalue" && !filterModeIsBeingDisabled) {
|
|
127
116
|
if (this.text.data === "") return this.forceEmptyValue();
|
|
128
|
-
if (this.getAttribute(attrs["aria-expanded"]) !== String(true)) return; // A valid value should already exist
|
|
117
|
+
if (this.getAttribute(attrs["aria-expanded"]) !== String(true) && hasOptions) return; // A valid value should already exist
|
|
129
118
|
|
|
130
119
|
if (this.#autoselectableOption) this.value = this.#autoselectableOption.value;
|
|
131
120
|
else this.value = this.text.data;
|
|
132
121
|
}
|
|
133
122
|
// `clearable` activated (default when `filter` mode is ON)
|
|
134
123
|
else if (trueNewValue === "clearable" && !filterModeIsBeingDisabled) {
|
|
124
|
+
if (!hasOptions && trueOldValue === "anyvalue") return this.#forceNullValue();
|
|
135
125
|
if (this.text.data === "") return this.forceEmptyValue();
|
|
136
126
|
if (trueOldValue !== "anyvalue") return; // A valid value should already exist
|
|
137
127
|
|
|
@@ -140,6 +130,7 @@ class ComboboxField extends HTMLElement {
|
|
|
140
130
|
}
|
|
141
131
|
// `unclearable` activated (default when `filter` mode is OFF)
|
|
142
132
|
else {
|
|
133
|
+
if (!hasOptions && (trueOldValue === "anyvalue" || filterModeIsBeingDisabled)) return this.#forceNullValue();
|
|
143
134
|
/** @type {ComboboxOption | null | undefined} */ let option;
|
|
144
135
|
|
|
145
136
|
if (trueOldValue !== "unclearable" && this.text.data === "") option = this.getOptionByValue("");
|
|
@@ -184,6 +175,10 @@ class ComboboxField extends HTMLElement {
|
|
|
184
175
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- This is due to our own TS Types. :\
|
|
185
176
|
this.#matchingOptions ??= Array.from(this.listbox?.children ?? []);
|
|
186
177
|
|
|
178
|
+
if (this.#value === null && this.valueIs === "anyvalue") {
|
|
179
|
+
this.attributeChangedCallback("valueis", /** @satisfies {this["valueIs"]} */ ("unclearable"), this.valueIs);
|
|
180
|
+
}
|
|
181
|
+
|
|
187
182
|
if (this.isConnected) {
|
|
188
183
|
if (/** @type {Document | ShadowRoot} */ (this.getRootNode()).activeElement === this) {
|
|
189
184
|
this.ownerDocument.getSelection()?.setBaseAndExtent(this.text, 0, this.text, this.text.length);
|
|
@@ -227,7 +222,11 @@ class ComboboxField extends HTMLElement {
|
|
|
227
222
|
// Setup Mutation Observers
|
|
228
223
|
this.#optionNodesObserver.observe(this.listbox, { childList: true });
|
|
229
224
|
this.#textNodeObserver.observe(this, { childList: true });
|
|
230
|
-
this.#expansionObserver.observe(this, {
|
|
225
|
+
this.#expansionObserver.observe(this, {
|
|
226
|
+
attributes: true,
|
|
227
|
+
attributeFilter: [attrs["aria-expanded"]],
|
|
228
|
+
attributeOldValue: true,
|
|
229
|
+
});
|
|
231
230
|
this.#activeDescendantObserver.observe(this, {
|
|
232
231
|
attributes: true,
|
|
233
232
|
attributeFilter: [attrs["aria-activedescendant"]],
|
|
@@ -283,7 +282,6 @@ class ComboboxField extends HTMLElement {
|
|
|
283
282
|
#handleTypeahead = (event) => {
|
|
284
283
|
const combobox = /** @type {ComboboxField} */ (event.currentTarget);
|
|
285
284
|
const { listbox } = combobox;
|
|
286
|
-
// TODO: This will probably be faster with getElementById?
|
|
287
285
|
const activeOption = listbox.querySelector(":scope [role='option'][data-active='true']");
|
|
288
286
|
|
|
289
287
|
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
|
@@ -373,7 +371,6 @@ class ComboboxField extends HTMLElement {
|
|
|
373
371
|
if (prevOption?.selected) prevOption.selected = false;
|
|
374
372
|
this.#validateRequiredConstraint();
|
|
375
373
|
|
|
376
|
-
// TODO: We might want to document that this `InputEvent` is not cancelable (and why)
|
|
377
374
|
combobox[editingKey] = true;
|
|
378
375
|
combobox.dispatchEvent(
|
|
379
376
|
new InputEvent("input", {
|
|
@@ -418,7 +415,7 @@ class ComboboxField extends HTMLElement {
|
|
|
418
415
|
for (let option = this.listbox.firstElementChild; option; option = /** @type {any} */ (option.nextElementSibling)) {
|
|
419
416
|
if (!this.optionMatchesFilter(option)) option.filteredOut = true;
|
|
420
417
|
else {
|
|
421
|
-
if (option.textContent === search) autoselectableOption = option;
|
|
418
|
+
if (option.textContent === search && !option.disabled) autoselectableOption = option;
|
|
422
419
|
|
|
423
420
|
option.filteredOut = false;
|
|
424
421
|
this.#matchingOptions[matches++] = option;
|
|
@@ -462,6 +459,12 @@ class ComboboxField extends HTMLElement {
|
|
|
462
459
|
* @returns {void}
|
|
463
460
|
*/
|
|
464
461
|
#resetOptions() {
|
|
462
|
+
/*
|
|
463
|
+
* TODO: If we ever decide to create a public, overridable `getResetOptions()` method, we should consider
|
|
464
|
+
* using this private internal method to initialize the component's `option`s onMount if no `#matchingOptions`
|
|
465
|
+
* exist yet. The reason is that doing so will GUARANTEE that the developer's `matchingOption`s will be kept
|
|
466
|
+
* in sync with ours from the get go.
|
|
467
|
+
*/
|
|
465
468
|
let i = 0;
|
|
466
469
|
for (let option = this.listbox.firstElementChild; option; option = /** @type {any} */ (option.nextElementSibling)) {
|
|
467
470
|
option.filteredOut = false;
|
|
@@ -482,8 +485,9 @@ class ComboboxField extends HTMLElement {
|
|
|
482
485
|
return this.#value;
|
|
483
486
|
}
|
|
484
487
|
|
|
485
|
-
/** @param {string}
|
|
486
|
-
set value(
|
|
488
|
+
/** @param {string} V */
|
|
489
|
+
set value(V) {
|
|
490
|
+
const v = typeof V === "string" ? V : String(V);
|
|
487
491
|
const newOption = this.getOptionByValue(v);
|
|
488
492
|
if (v === this.#value && newOption?.selected === true) return;
|
|
489
493
|
|
|
@@ -517,9 +521,8 @@ class ComboboxField extends HTMLElement {
|
|
|
517
521
|
if (this.valueIs !== "anyvalue" && this.valueIs !== "clearable") {
|
|
518
522
|
throw new TypeError('Method requires `filter` mode to be on and `valueis` to be "anyvalue" or "clearable"');
|
|
519
523
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
524
|
+
// Cannot coerce value to `""` for a `clearable` `combobox` that owns no `option`s
|
|
525
|
+
if (this.valueIs === "clearable" && this.#value === null) return;
|
|
523
526
|
|
|
524
527
|
const prevOption = this.#value == null ? null : this.getOptionByValue(this.#value);
|
|
525
528
|
|
|
@@ -531,6 +534,25 @@ class ComboboxField extends HTMLElement {
|
|
|
531
534
|
this.#validateRequiredConstraint();
|
|
532
535
|
}
|
|
533
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Coerces the `combobox`'s value to `null` and empties its text content.
|
|
539
|
+
*
|
|
540
|
+
* **NOTE: This method should only be called when an `(un)clearable` `combobox` is found to have no `option`s during
|
|
541
|
+
* a non-trivial state transition.**
|
|
542
|
+
* @returns {void}
|
|
543
|
+
*/
|
|
544
|
+
#forceNullValue() {
|
|
545
|
+
// NOTE: This error is only intended to help with internal development. If it causes problems for devs, remove it.
|
|
546
|
+
if (this.listbox.children.length || this.valueIs === "anyvalue") {
|
|
547
|
+
throw new TypeError("Method can only be called when an `(un)clearable` `combobox` has no `option`s");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
this.#value = null;
|
|
551
|
+
this.#internals.setFormValue(null);
|
|
552
|
+
this.text.data = "";
|
|
553
|
+
this.#validateRequiredConstraint();
|
|
554
|
+
}
|
|
555
|
+
|
|
534
556
|
/**
|
|
535
557
|
* Retrieves the `option` with the provided `value` (if it exists)
|
|
536
558
|
* @param {string} value
|
|
@@ -557,7 +579,7 @@ class ComboboxField extends HTMLElement {
|
|
|
557
579
|
}
|
|
558
580
|
|
|
559
581
|
set disabled(value) {
|
|
560
|
-
this.toggleAttribute("disabled",
|
|
582
|
+
this.toggleAttribute("disabled", value);
|
|
561
583
|
}
|
|
562
584
|
|
|
563
585
|
/** @returns {HTMLInputElement["required"]} */
|
|
@@ -566,7 +588,7 @@ class ComboboxField extends HTMLElement {
|
|
|
566
588
|
}
|
|
567
589
|
|
|
568
590
|
set required(value) {
|
|
569
|
-
this.toggleAttribute("required",
|
|
591
|
+
this.toggleAttribute("required", value);
|
|
570
592
|
}
|
|
571
593
|
|
|
572
594
|
/**
|
|
@@ -597,7 +619,7 @@ class ComboboxField extends HTMLElement {
|
|
|
597
619
|
}
|
|
598
620
|
|
|
599
621
|
set filter(value) {
|
|
600
|
-
this.toggleAttribute("filter",
|
|
622
|
+
this.toggleAttribute("filter", value);
|
|
601
623
|
}
|
|
602
624
|
|
|
603
625
|
/**
|
|
@@ -627,10 +649,7 @@ class ComboboxField extends HTMLElement {
|
|
|
627
649
|
* - `anyvalue`: The field's `value` can be any string, and it will automatically be set to
|
|
628
650
|
* whatever value the user types. (Requires enabling `filter` mode.)
|
|
629
651
|
*
|
|
630
|
-
*
|
|
631
|
-
* TODO: Link to Documentation for More Details (like TS does for MDN). The deeper details of the behavior
|
|
632
|
-
* are too sophisticated to place them all in a JSDoc, which should be [sufficiently] clear and succinct
|
|
633
|
-
* -->
|
|
652
|
+
* [API Reference](https://github.com/ITenthusiasm/custom-elements/blob/main/src/Combobox/docs/combobox-field.md#attributes-valueis)
|
|
634
653
|
*
|
|
635
654
|
* @returns {"unclearable" | "clearable" | "anyvalue"}
|
|
636
655
|
*/
|
|
@@ -663,7 +682,7 @@ class ComboboxField extends HTMLElement {
|
|
|
663
682
|
* Returns the `option` whose `label` matches the user's most recent filter input (if one exists).
|
|
664
683
|
*
|
|
665
684
|
* Value will be `null` if:
|
|
666
|
-
* - The user's filter didn't match any `option`s
|
|
685
|
+
* - The user's filter didn't match any (enabled) `option`s
|
|
667
686
|
* - The `combobox`'s text content was altered by a `value` change
|
|
668
687
|
* - The `combobox` was just recently expanded
|
|
669
688
|
* @returns {ComboboxOption | null}
|
|
@@ -763,7 +782,7 @@ class ComboboxField extends HTMLElement {
|
|
|
763
782
|
const defaultOption = listbox.querySelector(":scope [role='option']:nth-last-child(1 of [selected])");
|
|
764
783
|
|
|
765
784
|
if (defaultOption) this.value = defaultOption.value;
|
|
766
|
-
else if (this.valueIs === "anyvalue" || this.valueIs === "clearable") this.
|
|
785
|
+
else if (this.valueIs === "anyvalue" || this.valueIs === "clearable") this.forceEmptyValue();
|
|
767
786
|
else if (listbox.firstElementChild) this.value = listbox.firstElementChild.value;
|
|
768
787
|
}
|
|
769
788
|
|
|
@@ -924,6 +943,19 @@ class ComboboxField extends HTMLElement {
|
|
|
924
943
|
}
|
|
925
944
|
|
|
926
945
|
if (event.key === " ") {
|
|
946
|
+
/*
|
|
947
|
+
* TODO: Right now, we only support blocking `SpaceBar` and `Enter`. Should we support blocking ALL event keys?
|
|
948
|
+
* Doing so would require us to allow blocking the `typeahead` functionality as well (to keep things consistent).
|
|
949
|
+
* (Note: Filtering can already be blocked today because `beforeinput` handlers don't run if the `keydown` event
|
|
950
|
+
* is prevented.)
|
|
951
|
+
*
|
|
952
|
+
* If we decide to support blocking ALL keys, then we'll have to refactor the way we're using `#handleTypeahead`.
|
|
953
|
+
* More than likely, we'd have to call it inside `#handleKeydown` to make sure that SpaceBar searching doesn't
|
|
954
|
+
* break when `event.preventDefault()` is called from within the `#handleKeydown` event handler.
|
|
955
|
+
*
|
|
956
|
+
* But before we start rearranging method calls, we need to know if devs actually care about this kind of feature.
|
|
957
|
+
*/
|
|
958
|
+
if (event.defaultPrevented) return;
|
|
927
959
|
if (combobox.filter) return; // Defer to `#handleSearch` instead
|
|
928
960
|
event.preventDefault(); // Don't scroll
|
|
929
961
|
|
|
@@ -936,6 +968,7 @@ class ComboboxField extends HTMLElement {
|
|
|
936
968
|
}
|
|
937
969
|
|
|
938
970
|
if (event.key === "Enter") {
|
|
971
|
+
if (event.defaultPrevented) return;
|
|
939
972
|
// Prevent `#handleSearch` from triggering
|
|
940
973
|
if (combobox.filter) event.preventDefault();
|
|
941
974
|
|
|
@@ -1019,59 +1052,69 @@ class ComboboxField extends HTMLElement {
|
|
|
1019
1052
|
* @returns {void}
|
|
1020
1053
|
*/
|
|
1021
1054
|
#watchExpansion(mutations) {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const combobox = /** @type {ComboboxField} */ (mutation.target);
|
|
1025
|
-
const expanded = combobox.getAttribute(attrs["aria-expanded"]) === String(true);
|
|
1026
|
-
|
|
1027
|
-
// Open Combobox
|
|
1028
|
-
if (expanded) {
|
|
1029
|
-
/*
|
|
1030
|
-
* NOTE: If the user opens the `combobox` with search/typeahead, then `aria-activedescendant` will already
|
|
1031
|
-
* exist and this expansion logic will be irrelevant. Remember that `MutationObserver` callbacks are run
|
|
1032
|
-
* asynchronously, so this check would happen AFTER the search/typeahead handler completed. It's also
|
|
1033
|
-
* possible for this condition to be met if we redundantly set `aria-expanded`. Although we should be
|
|
1034
|
-
* be able to avoid that, we can't prevent Developers from accidentally doing that themselves.
|
|
1035
|
-
*/
|
|
1036
|
-
if (combobox.getAttribute(attrs["aria-activedescendant"]) !== "") return;
|
|
1037
|
-
|
|
1038
|
-
/** @type {ComboboxOption | null} */
|
|
1039
|
-
const selectedOption = combobox.value == null ? null : combobox.getOptionByValue(combobox.value);
|
|
1040
|
-
let activeOption = selectedOption ?? combobox.listbox.firstElementChild;
|
|
1041
|
-
if (combobox.filter && activeOption?.filteredOut) [activeOption] = this.#matchingOptions;
|
|
1042
|
-
|
|
1043
|
-
if (combobox.filter) {
|
|
1044
|
-
this.#autoselectableOption = null;
|
|
1045
|
-
this.#activeIndex = activeOption ? this.#matchingOptions.indexOf(activeOption) : -1;
|
|
1046
|
-
}
|
|
1055
|
+
const combobox = /** @type {ComboboxField} */ (mutations[0].target);
|
|
1056
|
+
const ariaExpanded = combobox.getAttribute(attrs["aria-expanded"]);
|
|
1047
1057
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
else {
|
|
1052
|
-
combobox.setAttribute(attrs["aria-activedescendant"], "");
|
|
1053
|
-
this.#searchString = "";
|
|
1058
|
+
const oldState = mutations[0].oldValue === String(true) ? "open" : "closed";
|
|
1059
|
+
const newState = ariaExpanded === String(true) ? "open" : "closed";
|
|
1060
|
+
const event = new ToggleEvent("toggle", { newState, oldState });
|
|
1054
1061
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1062
|
+
if (newState === oldState) {
|
|
1063
|
+
if (mutations.some((m) => m.oldValue !== ariaExpanded)) combobox.dispatchEvent(event);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Open Combobox
|
|
1068
|
+
/* eslint-disable no-labels, no-restricted-syntax */
|
|
1069
|
+
$: if (newState === "open") {
|
|
1070
|
+
/*
|
|
1071
|
+
* NOTE: If the user opens the `combobox` with search/typeahead, then `aria-activedescendant` will already
|
|
1072
|
+
* exist and this expansion logic will be irrelevant. Remember that `MutationObserver` callbacks are run
|
|
1073
|
+
* asynchronously, so this check would happen AFTER the search/typeahead handler completed. It's also
|
|
1074
|
+
* possible for this condition to be met if we redundantly set `aria-expanded`. Although we should be
|
|
1075
|
+
* be able to avoid that, we can't prevent Developers from accidentally doing that themselves.
|
|
1076
|
+
*/
|
|
1077
|
+
if (combobox.getAttribute(attrs["aria-activedescendant"]) !== "") break $;
|
|
1067
1078
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1079
|
+
/** @type {ComboboxOption | null} */
|
|
1080
|
+
const selectedOption = combobox.value == null ? null : combobox.getOptionByValue(combobox.value);
|
|
1081
|
+
let activeOption = selectedOption ?? combobox.listbox.firstElementChild;
|
|
1082
|
+
if (combobox.filter && activeOption?.filteredOut) [activeOption] = this.#matchingOptions;
|
|
1070
1083
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1084
|
+
if (combobox.filter) {
|
|
1085
|
+
this.#autoselectableOption = null;
|
|
1086
|
+
this.#activeIndex = activeOption ? this.#matchingOptions.indexOf(activeOption) : -1;
|
|
1073
1087
|
}
|
|
1088
|
+
|
|
1089
|
+
if (activeOption) combobox.setAttribute(attrs["aria-activedescendant"], activeOption.id);
|
|
1074
1090
|
}
|
|
1091
|
+
// Close Combobox
|
|
1092
|
+
else {
|
|
1093
|
+
combobox.setAttribute(attrs["aria-activedescendant"], "");
|
|
1094
|
+
this.#searchString = "";
|
|
1095
|
+
|
|
1096
|
+
// See if logic _exclusive_ to `filter`ed `combobox`es needs to be run
|
|
1097
|
+
if (!combobox.filter || combobox.value == null) break $;
|
|
1098
|
+
this.#resetOptions();
|
|
1099
|
+
|
|
1100
|
+
// Reset `combobox` display if needed
|
|
1101
|
+
// NOTE: `option` CAN be `null` or unselected if `combobox` is `clearable`, empty, and `collapsed` with a non-empty filter
|
|
1102
|
+
const textNode = combobox.text;
|
|
1103
|
+
if (!combobox.acceptsValue(textNode.data)) {
|
|
1104
|
+
const option = combobox.getOptionByValue(combobox.value);
|
|
1105
|
+
if (combobox.valueIs === "clearable" && !combobox.value && !option?.selected) textNode.data = "";
|
|
1106
|
+
else if (textNode.data !== option?.textContent) textNode.data = /** @type {string} */ (option?.textContent);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Reset cursor if `combobox` is still `:focus`ed
|
|
1110
|
+
if (/** @type {Document | ShadowRoot} */ (combobox.getRootNode()).activeElement !== combobox) break $;
|
|
1111
|
+
|
|
1112
|
+
const selection = /** @type {Selection} */ (combobox.ownerDocument.getSelection());
|
|
1113
|
+
selection.setBaseAndExtent(textNode, textNode.length, textNode, textNode.length);
|
|
1114
|
+
}
|
|
1115
|
+
/* eslint-enable no-labels, no-restricted-syntax */
|
|
1116
|
+
|
|
1117
|
+
combobox.dispatchEvent(event);
|
|
1075
1118
|
}
|
|
1076
1119
|
|
|
1077
1120
|
/**
|
|
@@ -1132,12 +1175,7 @@ class ComboboxField extends HTMLElement {
|
|
|
1132
1175
|
|
|
1133
1176
|
if (!this.listbox.children.length) {
|
|
1134
1177
|
if (!nullable) this.value = textNode.data;
|
|
1135
|
-
else
|
|
1136
|
-
this.#value = null;
|
|
1137
|
-
this.#internals.setFormValue(null);
|
|
1138
|
-
textNode.data = "";
|
|
1139
|
-
this.#validateRequiredConstraint();
|
|
1140
|
-
}
|
|
1178
|
+
else this.#forceNullValue();
|
|
1141
1179
|
|
|
1142
1180
|
if (this.filter) this.#filterOptions(); // Clean up internal data and show "No Matches" Message
|
|
1143
1181
|
return;
|
|
@@ -1155,13 +1193,13 @@ class ComboboxField extends HTMLElement {
|
|
|
1155
1193
|
if (this.valueIs !== "clearable") this.value = node.value;
|
|
1156
1194
|
else {
|
|
1157
1195
|
this.#value = "";
|
|
1158
|
-
this.
|
|
1196
|
+
this.#internals.setFormValue("");
|
|
1159
1197
|
}
|
|
1160
1198
|
}
|
|
1161
1199
|
});
|
|
1162
1200
|
|
|
1163
1201
|
mutation.removedNodes.forEach((node) => {
|
|
1164
|
-
if (!(node instanceof ComboboxOption)) return;
|
|
1202
|
+
if (!(node instanceof ComboboxOption) || this.listbox.contains(node)) return;
|
|
1165
1203
|
if (this.#autoselectableOption === node) this.#autoselectableOption = null;
|
|
1166
1204
|
if (node.selected) {
|
|
1167
1205
|
if (nullable) this.formResetCallback();
|