@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.
@@ -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
+ }
@@ -4,7 +4,7 @@ select-enhancer {
4
4
  position: relative;
5
5
  display: inline-block;
6
6
  box-sizing: border-box;
7
- width: inherit;
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: center;
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"])::after {
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: auto;
91
+ cursor: not-allowed;
91
92
  }
92
93
  }
93
94
 
94
- &:where([role="combobox"][data-bad-filter] + [role="listbox"])::after {
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} v */
75
- set value(v: string);
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 | "normalize" | "link" | "search" | "small" | "sub" | "sup" | "length" | "toString" | "concat" | "slice" | "indexOf" | "lastIndexOf" | "includes" | "at" | "charAt" | "charCodeAt" | "localeCompare" | "match" | "replace" | "split" | "substring" | "toLowerCase" | "toLocaleLowerCase" | "toUpperCase" | "toLocaleUpperCase" | "trim" | "substr" | "valueOf" | "codePointAt" | "endsWith" | "repeat" | "startsWith" | "anchor" | "big" | "blink" | "bold" | "fixed" | "fontcolor" | "fontsize" | "italics" | "strike" | "padStart" | "padEnd" | "trimEnd" | "trimStart" | "trimLeft" | "trimRight" | "matchAll" | "replaceAll" | "isWellFormed" | "toWellFormed", "startsWith" | "includes">);
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 | "normalize" | "link" | "search" | "small" | "sub" | "sup" | "length" | "toString" | "concat" | "slice" | "indexOf" | "lastIndexOf" | "includes" | "at" | "charAt" | "charCodeAt" | "localeCompare" | "match" | "replace" | "split" | "substring" | "toLowerCase" | "toLocaleLowerCase" | "toUpperCase" | "toLocaleUpperCase" | "trim" | "substr" | "valueOf" | "codePointAt" | "endsWith" | "repeat" | "startsWith" | "anchor" | "big" | "blink" | "bold" | "fixed" | "fontcolor" | "fontsize" | "italics" | "strike" | "padStart" | "padEnd" | "trimEnd" | "trimStart" | "trimLeft" | "trimRight" | "matchAll" | "replaceAll" | "isWellFormed" | "toWellFormed", "startsWith" | "includes">;
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, { attributes: true, attributeFilter: [attrs["aria-expanded"]] });
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} v */
486
- set value(v) {
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
- if (this.valueIs === "clearable" && this.#value === null) {
521
- throw new TypeError('Cannot coerce value to `""` for a `clearable` `combobox` that owns no `option`s');
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", Boolean(value));
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", Boolean(value));
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", Boolean(value));
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.value = "";
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
- for (let i = 0; i < mutations.length; i++) {
1023
- const mutation = mutations[i];
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
- if (activeOption) combobox.setAttribute(attrs["aria-activedescendant"], activeOption.id);
1049
- }
1050
- // Close Combobox
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
- // See if logic _exclusive_ to `filter`ed `combobox`es needs to be run
1056
- if (!combobox.filter || combobox.value == null) return;
1057
- this.#resetOptions();
1058
-
1059
- // Reset `combobox` display if needed
1060
- // NOTE: `option` CAN be `null` or unselected if `combobox` is `clearable`, empty, and `collapsed` with a non-empty filter
1061
- const textNode = combobox.text;
1062
- if (!combobox.acceptsValue(textNode.data)) {
1063
- const option = combobox.getOptionByValue(combobox.value);
1064
- if (combobox.valueIs === "clearable" && !combobox.value && !option?.selected) textNode.data = "";
1065
- else if (textNode.data !== option?.textContent) textNode.data = /** @type {string} */ (option?.textContent);
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
- // Reset cursor if `combobox` is still `:focus`ed
1069
- if (/** @type {Document | ShadowRoot} */ (combobox.getRootNode()).activeElement !== combobox) return;
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
- const selection = /** @type {Selection} */ (combobox.ownerDocument.getSelection());
1072
- selection.setBaseAndExtent(textNode, textNode.length, textNode, textNode.length);
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.forceEmptyValue();
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();