@lekoala/combobox 0.1.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +566 -0
  3. package/custom-elements.json +247 -0
  4. package/dist/combobox.css +447 -0
  5. package/dist/combobox.js +2220 -0
  6. package/dist/combobox.min.css +1 -0
  7. package/dist/combobox.min.js +2 -0
  8. package/dist/types/combo-box.d.ts +112 -0
  9. package/dist/types/combo-box.d.ts.map +1 -0
  10. package/dist/types/combobox.d.ts +530 -0
  11. package/dist/types/combobox.d.ts.map +1 -0
  12. package/dist/types/define.d.ts +2 -0
  13. package/dist/types/define.d.ts.map +1 -0
  14. package/dist/types/helpers.d.ts +251 -0
  15. package/dist/types/helpers.d.ts.map +1 -0
  16. package/dist/types/index.d.ts +24 -0
  17. package/dist/types/index.d.ts.map +1 -0
  18. package/dist/types/locales/de.d.ts +7 -0
  19. package/dist/types/locales/de.d.ts.map +1 -0
  20. package/dist/types/locales/en.d.ts +8 -0
  21. package/dist/types/locales/en.d.ts.map +1 -0
  22. package/dist/types/locales/es.d.ts +7 -0
  23. package/dist/types/locales/es.d.ts.map +1 -0
  24. package/dist/types/locales/fr.d.ts +7 -0
  25. package/dist/types/locales/fr.d.ts.map +1 -0
  26. package/dist/types/locales/it.d.ts +7 -0
  27. package/dist/types/locales/it.d.ts.map +1 -0
  28. package/dist/types/locales/nl.d.ts +7 -0
  29. package/dist/types/locales/nl.d.ts.map +1 -0
  30. package/dist/types/locales/pt.d.ts +7 -0
  31. package/dist/types/locales/pt.d.ts.map +1 -0
  32. package/dist/types/locales/ru.d.ts +7 -0
  33. package/dist/types/locales/ru.d.ts.map +1 -0
  34. package/dist/types/locales/zh-CN.d.ts +7 -0
  35. package/dist/types/locales/zh-CN.d.ts.map +1 -0
  36. package/dist/types/messages.d.ts +48 -0
  37. package/dist/types/messages.d.ts.map +1 -0
  38. package/package.json +92 -0
  39. package/src/combo-box.js +316 -0
  40. package/src/combobox.css +447 -0
  41. package/src/combobox.js +2975 -0
  42. package/src/define.js +20 -0
  43. package/src/helpers.js +377 -0
  44. package/src/index.js +35 -0
  45. package/src/locales/de.js +17 -0
  46. package/src/locales/en.js +18 -0
  47. package/src/locales/es.js +17 -0
  48. package/src/locales/fr.js +17 -0
  49. package/src/locales/it.js +17 -0
  50. package/src/locales/nl.js +17 -0
  51. package/src/locales/pt.js +17 -0
  52. package/src/locales/ru.js +17 -0
  53. package/src/locales/zh-CN.js +17 -0
  54. package/src/messages.js +55 -0
@@ -0,0 +1,316 @@
1
+ import { Combobox } from "./combobox.js";
2
+ import { booleanAttribute, hasOwn, parseInteger, parseList, parseSeparators } from "./helpers.js";
3
+
4
+ /**
5
+ * @typedef {import("./combobox.js").ComboboxOptions} ComboboxOptions
6
+ * @typedef {import("./combobox.js").ComboboxSource} ComboboxSource
7
+ */
8
+
9
+ /**
10
+ * Configuration of one `<combo-box>` attribute → option mapping.
11
+ * @typedef {Object} AttributeConfig
12
+ * @property {"boolean" | "integer"} [type] Built-in converter
13
+ * @property {string} [option] Override of the default kebab→camelCase name
14
+ * @property {(raw: string | null) => any} [parse] Explicit converter
15
+ */
16
+
17
+ /**
18
+ * Declarative surface: the canonical mapping of `<combo-box>` attributes to
19
+ * engine options. This schema drives both `observedAttributes` and the option
20
+ * resolver, so anything listed here is part of the HTML API — nothing is
21
+ * inferred from `DEFAULTS`. `option` overrides the default kebab→camelCase
22
+ * name; `parse` is an explicit converter; `type` selects a built-in converter
23
+ * (`boolean`, `integer`, otherwise raw string).
24
+ * @type {Record<string, AttributeConfig>}
25
+ */
26
+ const OPTION_ATTRIBUTES = {
27
+ create: { type: "boolean" },
28
+ placeholder: {},
29
+ search: { option: "match" },
30
+ "min-chars": { type: "integer" },
31
+ "max-items": { type: "integer" },
32
+ "max-options": { type: "integer" },
33
+ "selection-order": {},
34
+ separators: { parse: parseSeparators },
35
+ "create-on-blur": { type: "boolean" },
36
+ "close-on-select": { type: "boolean" },
37
+ "autoselect-first": { type: "boolean" },
38
+ "tab-select": { type: "boolean" },
39
+ "search-fields": { parse: parseList },
40
+ "label-field": {},
41
+ "value-field": {},
42
+ "load-on-empty": { type: "boolean" },
43
+ "allow-empty-option": { type: "boolean" },
44
+ debounce: { type: "integer" },
45
+ };
46
+
47
+ /**
48
+ * @param {string} name
49
+ * @returns {string}
50
+ */
51
+ function camelCase(name) {
52
+ return name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
53
+ }
54
+
55
+ /**
56
+ * Lightweight Custom Element owner for the Combobox enhancement engine.
57
+ *
58
+ * Intentional design choices:
59
+ * - autonomous custom element: <combo-box>
60
+ * - no Shadow DOM: native source/form/label semantics remain visible
61
+ * - not form-associated: the child <input>/<select> remains the value owner
62
+ * - no automatic registration: call defineCombobox() explicitly
63
+ * - JS options may be assigned before custom-element registration
64
+ */
65
+ export class ComboBoxElement extends HTMLElement {
66
+ static get observedAttributes() {
67
+ return Object.keys(OPTION_ATTRIBUTES);
68
+ }
69
+
70
+ constructor() {
71
+ super();
72
+ /** @type {Combobox | null} */
73
+ this._combobox = null;
74
+ /** @type {ComboboxSource | null} */
75
+ this._source = null;
76
+ /** @type {ComboboxOptions} */
77
+ this._options = {};
78
+ /** @type {MutationObserver | null} */
79
+ this._sourceObserver = null;
80
+ this._revision = 0;
81
+ this._rebuildQueued = false;
82
+ /** @type {Array<(combobox: Combobox) => void>} */
83
+ this._readyResolvers = [];
84
+
85
+ // Upgrade properties assigned while <combo-box> was still an unknown
86
+ // element, e.g. box.options = { load() {} }; defineCombobox();
87
+ this.#upgradeProperty("options");
88
+ }
89
+
90
+ connectedCallback() {
91
+ const revision = ++this._revision;
92
+ queueMicrotask(() => {
93
+ if (revision !== this._revision || !this.isConnected) return;
94
+ this.upgrade();
95
+ });
96
+ }
97
+
98
+ disconnectedCallback() {
99
+ const revision = ++this._revision;
100
+ // DOM moves commonly disconnect/reconnect synchronously. Deferring teardown
101
+ // avoids destroying state for a simple move while still cleaning removals.
102
+ queueMicrotask(() => {
103
+ if (revision !== this._revision || this.isConnected) return;
104
+ this.dispose();
105
+ });
106
+ }
107
+
108
+ /**
109
+ * @param {string} _name
110
+ * @param {string | null} oldValue
111
+ * @param {string | null} newValue
112
+ */
113
+ attributeChangedCallback(_name, oldValue, newValue) {
114
+ if (oldValue === newValue || !this._combobox) return;
115
+ this.#scheduleRebuild();
116
+ }
117
+
118
+ /**
119
+ * The enhanced source element, discovering it lazily when needed.
120
+ * @public
121
+ * @returns {ComboboxSource | null}
122
+ */
123
+ get source() {
124
+ return this._source || this.#findSource();
125
+ }
126
+
127
+ /**
128
+ * The underlying Combobox engine instance, or null before upgrade.
129
+ * @public
130
+ * @returns {Combobox | null}
131
+ */
132
+ get combobox() {
133
+ return this._combobox;
134
+ }
135
+
136
+ /**
137
+ * The merged JavaScript options currently applied to the element.
138
+ * @public
139
+ * @returns {ComboboxOptions}
140
+ */
141
+ get options() {
142
+ return { ...this._options };
143
+ }
144
+
145
+ /**
146
+ * @param {ComboboxOptions} value
147
+ */
148
+ set options(value) {
149
+ if (value == null) value = {};
150
+ if (typeof value !== "object") throw new TypeError("combo-box options must be an object");
151
+ this._options = { ...value };
152
+ if (this._combobox) this.#scheduleRebuild();
153
+ }
154
+
155
+ /**
156
+ * Merge JavaScript-only behavior such as load/create/render callbacks.
157
+ * @public
158
+ * @param {ComboboxOptions} [options]
159
+ * @returns {this}
160
+ */
161
+ configure(options = {}) {
162
+ this.options = { ...this._options, ...options };
163
+ return this;
164
+ }
165
+
166
+ /**
167
+ * Enhance the native child source. Safe to call repeatedly.
168
+ * @public
169
+ * @returns {Combobox | null} The Combobox instance, or null until a source
170
+ * child exists.
171
+ */
172
+ upgrade() {
173
+ const source = this.#findSource();
174
+ if (!source) {
175
+ this.#watchForSource();
176
+ return null;
177
+ }
178
+
179
+ // The engine is imported statically above, so a missing source child is
180
+ // the only reason to defer enhancement.
181
+ this._sourceObserver?.disconnect();
182
+ this._sourceObserver = null;
183
+
184
+ if (this._combobox && this._source === source) return this._combobox;
185
+ if (this._combobox) this._combobox.dispose();
186
+
187
+ this._source = source;
188
+ this._combobox = new Combobox(source, this.#resolvedOptions());
189
+
190
+ const ready = this._readyResolvers.splice(0);
191
+ for (const resolve of ready) resolve(this._combobox);
192
+
193
+ this.dispatchEvent(
194
+ new CustomEvent("combobox:ready", {
195
+ bubbles: true,
196
+ detail: { combobox: this._combobox, source },
197
+ }),
198
+ );
199
+
200
+ return this._combobox;
201
+ }
202
+
203
+ /**
204
+ * Resolves once the engine has upgraded the source child.
205
+ * @public
206
+ * @returns {Promise<Combobox>}
207
+ */
208
+ whenReady() {
209
+ if (this._combobox) return Promise.resolve(this._combobox);
210
+ return new Promise((resolve) => this._readyResolvers.push(resolve));
211
+ }
212
+
213
+ /**
214
+ * Tear the engine down and restore the native source.
215
+ * @public
216
+ */
217
+ dispose() {
218
+ this._sourceObserver?.disconnect();
219
+ this._sourceObserver = null;
220
+ this._combobox?.dispose();
221
+ this._combobox = null;
222
+ this._source = null;
223
+ }
224
+
225
+ /**
226
+ * @returns {ComboboxSource | null}
227
+ */
228
+ #findSource() {
229
+ // A select wins because a select-backed combobox may also contain its
230
+ // explicit filter <input>. Otherwise require input[list] for free text.
231
+ for (const child of this.children) {
232
+ if (child instanceof HTMLSelectElement) return child;
233
+ }
234
+ for (const child of this.children) {
235
+ if (child instanceof HTMLInputElement && child.hasAttribute("list")) return child;
236
+ }
237
+ return null;
238
+ }
239
+
240
+ #watchForSource() {
241
+ if (this._sourceObserver) return;
242
+ this._sourceObserver = new MutationObserver(() => {
243
+ if (this.#findSource()) this.upgrade();
244
+ });
245
+ this._sourceObserver.observe(this, { childList: true });
246
+ }
247
+
248
+ /**
249
+ * @returns {ComboboxOptions}
250
+ */
251
+ #resolvedOptions() {
252
+ /** @type {Record<string, any>} */
253
+ const attrs = {};
254
+
255
+ for (const [attribute, config] of Object.entries(OPTION_ATTRIBUTES)) {
256
+ if (!this.hasAttribute(attribute)) continue;
257
+ const raw = this.getAttribute(attribute);
258
+ const value = config.parse
259
+ ? config.parse(raw)
260
+ : config.type === "boolean"
261
+ ? booleanAttribute(this, attribute)
262
+ : config.type === "integer"
263
+ ? parseInteger(raw)
264
+ : raw;
265
+ // undefined means the attribute was authored with a value that does not
266
+ // convert (e.g. an integer option got "banana") — skip it so DEFAULTS wins.
267
+ if (value !== undefined) attrs[config.option ?? camelCase(attribute)] = value;
268
+ }
269
+
270
+ // JS wins over markup for behavior that needs an explicit override.
271
+ return /** @type {ComboboxOptions} */ ({ ...attrs, ...this._options });
272
+ }
273
+
274
+ #scheduleRebuild() {
275
+ if (this._rebuildQueued) return;
276
+ this._rebuildQueued = true;
277
+ queueMicrotask(() => {
278
+ this._rebuildQueued = false;
279
+ if (!this.isConnected || !this._combobox) return;
280
+ const source = this._source;
281
+ this._combobox.dispose();
282
+ this._combobox = null;
283
+ this._source = source;
284
+ this.upgrade();
285
+ });
286
+ }
287
+
288
+ /**
289
+ * @param {string} name
290
+ */
291
+ #upgradeProperty(name) {
292
+ if (!hasOwn(this, name)) return;
293
+ // Reflect keeps the object index dynamic without aliasing `this` (which
294
+ // Biome flags) or widening the element type with a cast per access.
295
+ const value = Reflect.get(this, name);
296
+ Reflect.deleteProperty(this, name);
297
+ Reflect.set(this, name, value);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Register <combo-box> once in the global CustomElementRegistry.
303
+ * The official component name is fixed: a repeated call is an idempotent
304
+ * no-op and a foreign element already owning the name is left untouched.
305
+ * For an application-specific tag name, subclass the exported ComboBoxElement
306
+ * and register natively:
307
+ *
308
+ * customElements.define("my-tag", class extends ComboBoxElement {});
309
+ *
310
+ * @returns {typeof ComboBoxElement}
311
+ */
312
+ export function defineCombobox() {
313
+ const registry = globalThis.customElements;
314
+ if (!registry.get("combo-box")) registry.define("combo-box", ComboBoxElement);
315
+ return ComboBoxElement;
316
+ }