@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.
- package/LICENSE +21 -0
- package/README.md +566 -0
- package/custom-elements.json +247 -0
- package/dist/combobox.css +447 -0
- package/dist/combobox.js +2220 -0
- package/dist/combobox.min.css +1 -0
- package/dist/combobox.min.js +2 -0
- package/dist/types/combo-box.d.ts +112 -0
- package/dist/types/combo-box.d.ts.map +1 -0
- package/dist/types/combobox.d.ts +530 -0
- package/dist/types/combobox.d.ts.map +1 -0
- package/dist/types/define.d.ts +2 -0
- package/dist/types/define.d.ts.map +1 -0
- package/dist/types/helpers.d.ts +251 -0
- package/dist/types/helpers.d.ts.map +1 -0
- package/dist/types/index.d.ts +24 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/locales/de.d.ts +7 -0
- package/dist/types/locales/de.d.ts.map +1 -0
- package/dist/types/locales/en.d.ts +8 -0
- package/dist/types/locales/en.d.ts.map +1 -0
- package/dist/types/locales/es.d.ts +7 -0
- package/dist/types/locales/es.d.ts.map +1 -0
- package/dist/types/locales/fr.d.ts +7 -0
- package/dist/types/locales/fr.d.ts.map +1 -0
- package/dist/types/locales/it.d.ts +7 -0
- package/dist/types/locales/it.d.ts.map +1 -0
- package/dist/types/locales/nl.d.ts +7 -0
- package/dist/types/locales/nl.d.ts.map +1 -0
- package/dist/types/locales/pt.d.ts +7 -0
- package/dist/types/locales/pt.d.ts.map +1 -0
- package/dist/types/locales/ru.d.ts +7 -0
- package/dist/types/locales/ru.d.ts.map +1 -0
- package/dist/types/locales/zh-CN.d.ts +7 -0
- package/dist/types/locales/zh-CN.d.ts.map +1 -0
- package/dist/types/messages.d.ts +48 -0
- package/dist/types/messages.d.ts.map +1 -0
- package/package.json +92 -0
- package/src/combo-box.js +316 -0
- package/src/combobox.css +447 -0
- package/src/combobox.js +2975 -0
- package/src/define.js +20 -0
- package/src/helpers.js +377 -0
- package/src/index.js +35 -0
- package/src/locales/de.js +17 -0
- package/src/locales/en.js +18 -0
- package/src/locales/es.js +17 -0
- package/src/locales/fr.js +17 -0
- package/src/locales/it.js +17 -0
- package/src/locales/nl.js +17 -0
- package/src/locales/pt.js +17 -0
- package/src/locales/ru.js +17 -0
- package/src/locales/zh-CN.js +17 -0
- package/src/messages.js +55 -0
package/src/combo-box.js
ADDED
|
@@ -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
|
+
}
|