@akonwi/mica 0.7.0 → 0.8.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.
Files changed (2) hide show
  1. package/combobox.js +52 -18
  2. package/package.json +29 -8
package/combobox.js CHANGED
@@ -20,13 +20,16 @@ let uid = 0;
20
20
  class MCombobox extends HTMLElement {
21
21
  #input;
22
22
  #list;
23
+ #datalist;
24
+ #listId;
23
25
  #options = [];
24
26
  #active = -1;
27
+ #observer;
25
28
 
26
29
  connectedCallback() {
27
30
  this.#input = this.querySelector("input");
28
- const datalist = this.querySelector("datalist");
29
- if (!this.#input || !datalist) return;
31
+ this.#datalist = this.querySelector("datalist");
32
+ if (!this.#input || !this.#datalist) return;
30
33
 
31
34
  // disable the native popup; the module takes over
32
35
  this.#input.removeAttribute("list");
@@ -35,16 +38,49 @@ class MCombobox extends HTMLElement {
35
38
  this.#input.setAttribute("aria-autocomplete", "list");
36
39
  this.#input.autocomplete = "off";
37
40
 
38
- const listId = `m-cb-${++uid}`;
41
+ this.#listId = `m-cb-${++uid}`;
39
42
  this.#list = document.createElement("div");
40
- this.#list.id = listId;
43
+ this.#list.id = this.#listId;
41
44
  this.#list.setAttribute("role", "listbox");
42
45
  this.#list.hidden = true;
43
46
 
44
- this.#options = [...datalist.options].map((o, i) => {
47
+ this.#buildOptions();
48
+ this.append(this.#list);
49
+ this.#input.setAttribute("aria-controls", this.#listId);
50
+
51
+ // Options may be rendered asynchronously (a search-backed combobox):
52
+ // rebuild the listbox whenever the author's datalist changes.
53
+ this.#observer = new MutationObserver(() => {
54
+ this.#buildOptions();
55
+ if (document.activeElement === this.#input) this.#openAndFilter();
56
+ });
57
+ this.#observer.observe(this.#datalist, {
58
+ childList: true,
59
+ subtree: true,
60
+ attributes: true,
61
+ attributeFilter: ["value"],
62
+ });
63
+
64
+ this.#input.addEventListener("input", (e) => {
65
+ // ignore our own dispatched events from #choose — they'd reopen
66
+ if (e.isTrusted) this.#openAndFilter();
67
+ });
68
+ this.#input.addEventListener("keydown", (e) => this.#onKey(e));
69
+ this.addEventListener("focusout", (e) => {
70
+ if (!this.contains(e.relatedTarget)) this.#close();
71
+ });
72
+ }
73
+
74
+ disconnectedCallback() {
75
+ this.#observer?.disconnect();
76
+ }
77
+
78
+ #buildOptions() {
79
+ this.#list.replaceChildren();
80
+ this.#options = [...this.#datalist.options].map((o, i) => {
45
81
  const d = document.createElement("div");
46
82
  d.setAttribute("role", "option");
47
- d.id = `${listId}-${i}`;
83
+ d.id = `${this.#listId}-${i}`;
48
84
  d.textContent = o.value || o.textContent;
49
85
  // pointerdown so the input never loses focus
50
86
  d.addEventListener("pointerdown", (e) => {
@@ -58,17 +94,7 @@ class MCombobox extends HTMLElement {
58
94
  this.#list.append(d);
59
95
  return d;
60
96
  });
61
- this.append(this.#list);
62
- this.#input.setAttribute("aria-controls", listId);
63
-
64
- this.#input.addEventListener("input", (e) => {
65
- // ignore our own dispatched events from #choose — they'd reopen
66
- if (e.isTrusted) this.#openAndFilter();
67
- });
68
- this.#input.addEventListener("keydown", (e) => this.#onKey(e));
69
- this.addEventListener("focusout", (e) => {
70
- if (!this.contains(e.relatedTarget)) this.#close();
71
- });
97
+ this.#active = -1;
72
98
  }
73
99
 
74
100
  #visible() {
@@ -127,7 +153,15 @@ class MCombobox extends HTMLElement {
127
153
  }
128
154
 
129
155
  #choose(option) {
130
- this.#input.value = option.textContent;
156
+ // Write through the native prototype setter: frameworks that patch
157
+ // the value property to track controlled inputs (React) only observe
158
+ // the change when it lands on the native setter.
159
+ const setter = Object.getOwnPropertyDescriptor(
160
+ HTMLInputElement.prototype,
161
+ "value",
162
+ )?.set;
163
+ if (setter) setter.call(this.#input, option.textContent);
164
+ else this.#input.value = option.textContent;
131
165
  this.#close();
132
166
  this.#input.dispatchEvent(new Event("input", { bubbles: true }));
133
167
  this.#input.dispatchEvent(new Event("change", { bubbles: true }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akonwi/mica",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Custom elements. Native behavior. Nearly no JavaScript.",
5
5
  "keywords": [
6
6
  "css",
@@ -21,13 +21,34 @@
21
21
  "exports": {
22
22
  ".": "./mica.css",
23
23
  "./mica.css": "./mica.css",
24
- "./invoker.js": { "types": "./invoker.d.ts", "default": "./invoker.js" },
25
- "./drawer.js": { "types": "./drawer.d.ts", "default": "./drawer.js" },
26
- "./field.js": { "types": "./field.d.ts", "default": "./field.js" },
27
- "./select.js": { "types": "./select.d.ts", "default": "./select.js" },
28
- "./tabs.js": { "types": "./tabs.d.ts", "default": "./tabs.js" },
29
- "./toast.js": { "types": "./toast.d.ts", "default": "./toast.js" },
30
- "./combobox.js": { "types": "./combobox.d.ts", "default": "./combobox.js" },
24
+ "./invoker.js": {
25
+ "types": "./invoker.d.ts",
26
+ "default": "./invoker.js"
27
+ },
28
+ "./drawer.js": {
29
+ "types": "./drawer.d.ts",
30
+ "default": "./drawer.js"
31
+ },
32
+ "./field.js": {
33
+ "types": "./field.d.ts",
34
+ "default": "./field.js"
35
+ },
36
+ "./select.js": {
37
+ "types": "./select.d.ts",
38
+ "default": "./select.js"
39
+ },
40
+ "./tabs.js": {
41
+ "types": "./tabs.d.ts",
42
+ "default": "./tabs.js"
43
+ },
44
+ "./toast.js": {
45
+ "types": "./toast.d.ts",
46
+ "default": "./toast.js"
47
+ },
48
+ "./combobox.js": {
49
+ "types": "./combobox.d.ts",
50
+ "default": "./combobox.js"
51
+ },
31
52
  "./types/react": {
32
53
  "types": "./types/react.d.ts"
33
54
  }