@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/define.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-effect entry: registers <combo-box> in the standard CustomElementRegistry.
|
|
3
|
+
*
|
|
4
|
+
* The only file in src/ allowed to define the element. The classic distribution
|
|
5
|
+
* build (dist/combobox.js) is produced from exactly this entry:
|
|
6
|
+
*
|
|
7
|
+
* bun build src/define.js --outfile=dist/combobox.js --format=iife
|
|
8
|
+
*
|
|
9
|
+
* Importing "@lekoala/combobox" (index.js) never registers anything; a consumer
|
|
10
|
+
* who wants the declarative element must opt in via any of:
|
|
11
|
+
* import "@lekoala/combobox/define";
|
|
12
|
+
* import { defineCombobox } from "@lekoala/combobox"; defineCombobox();
|
|
13
|
+
* <script src="dist/combobox.js"></script> // classic / file://
|
|
14
|
+
*
|
|
15
|
+
* The name is always "combo-box". For another tag, subclass the exported
|
|
16
|
+
* ComboBoxElement and register natively in application code.
|
|
17
|
+
*/
|
|
18
|
+
import { defineCombobox } from "./combo-box.js";
|
|
19
|
+
|
|
20
|
+
defineCombobox();
|
package/src/helpers.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers shared by the Combobox engine.
|
|
3
|
+
*
|
|
4
|
+
* Free of DOM/engine state and unit-tested directly as ESM. The global build
|
|
5
|
+
* (dist/combobox.js) inlines them through its single side-effect entry,
|
|
6
|
+
* src/define.js; nothing in src/ ever touches window/globalThis.
|
|
7
|
+
*
|
|
8
|
+
* Separator contract:
|
|
9
|
+
* - separator values are full strings, not a character class (`",|;"` means
|
|
10
|
+
* comma, pipe and semicolon are all independent separators);
|
|
11
|
+
* - the `|`-delimited form is the `<combo-box separators="…">` attribute
|
|
12
|
+
* encoding, so a literal `|` cannot be expressed as an attribute separator;
|
|
13
|
+
* - matching prefers the longest separator at any position.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Canonical combobox item. `value`/`label` are the serialized payload; the
|
|
18
|
+
* remaining keys mirror source metadata the engine reads back. Applications
|
|
19
|
+
* may hang their own payload on items (label/value fields are honored by the
|
|
20
|
+
* engine, a `data-*` mapping is not).
|
|
21
|
+
* @typedef {Record<string, any> & {
|
|
22
|
+
* value: string,
|
|
23
|
+
* label: string,
|
|
24
|
+
* disabled?: boolean,
|
|
25
|
+
* selected?: boolean,
|
|
26
|
+
* group?: string,
|
|
27
|
+
* option?: HTMLOptionElement | null,
|
|
28
|
+
* data?: Record<string, string | undefined>,
|
|
29
|
+
* title?: string,
|
|
30
|
+
* }} ComboboxItem
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Field mapping used to convert plain data objects into canonical items.
|
|
35
|
+
* @typedef {Object} ItemFields
|
|
36
|
+
* @property {string} [labelField]
|
|
37
|
+
* @property {string} [valueField]
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A completed separator-delimited token.
|
|
42
|
+
* @typedef {Object} Token
|
|
43
|
+
* @property {string} text
|
|
44
|
+
* @property {string} sep The separator that terminated the token
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Result of a tokenization pass.
|
|
49
|
+
* @typedef {Object} SplitResult
|
|
50
|
+
* @property {Token[]} done Completed tokens
|
|
51
|
+
* @property {string} rest Trailing unterminated text that must stay in the input
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} object
|
|
56
|
+
* @param {string} key
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function hasOwn(object, key) {
|
|
60
|
+
return Object.hasOwn(object, key);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {*} value
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function escapeRegExp(value) {
|
|
68
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Strip combining diacritics only (NFD + remove the U+0300–U+036F block).
|
|
73
|
+
* Case is preserved — use `normalize()` when case-insensitivity is also
|
|
74
|
+
* wanted. Everything in the engine that is "accent-insensitive" funnels
|
|
75
|
+
* through here, so there is exactly one definition of accent folding.
|
|
76
|
+
*
|
|
77
|
+
* @param {*} value
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function stripDiacritics(value) {
|
|
81
|
+
return String(value ?? "")
|
|
82
|
+
.normalize("NFD")
|
|
83
|
+
.replace(/[\u0300-\u036f]/g, "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {*} value
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function normalize(value) {
|
|
91
|
+
return stripDiacritics(value).toLocaleLowerCase();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Match a single `value` against `query` under a `match` strategy map
|
|
96
|
+
* ("includes" | "startswith" | "fuzzy" | "pattern").
|
|
97
|
+
*
|
|
98
|
+
* Contract: this helper decides **how a field matches** — it never receives
|
|
99
|
+
* more than one field value, so a match can never cross `searchFields`
|
|
100
|
+
* boundaries. The engine calls it as `values.some((value) => matchesField(value, query, mode))`.
|
|
101
|
+
*
|
|
102
|
+
* `pattern` is a string transformed into a `RegExp` with the `i` flag only
|
|
103
|
+
* (never a caller-supplied `RegExp`, so a stateful `g`/`y` matcher cannot
|
|
104
|
+
* leak between items). It first tests the raw value with the authored regex,
|
|
105
|
+
* then falls back to an accent-insensitive pass over `stripDiacritics()`
|
|
106
|
+
* text with a diacritics-folded spelling of the query — so `liège`, `Liège`,
|
|
107
|
+
* `LIEGE` and `liege` all match each other while `[A-Z]`-style regexes keep
|
|
108
|
+
* their original semantics (case is handled by `/i`, not by lowercasing).
|
|
109
|
+
* An invalid regex still yields `false` for every value.
|
|
110
|
+
*
|
|
111
|
+
* @param {*} value
|
|
112
|
+
* @param {*} query
|
|
113
|
+
* @param {*} mode
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export function matchesField(value, query, mode) {
|
|
117
|
+
const normalized = normalize(value);
|
|
118
|
+
const lookup = normalize(query);
|
|
119
|
+
switch (String(mode).toLowerCase()) {
|
|
120
|
+
case "startswith":
|
|
121
|
+
return normalized.startsWith(lookup);
|
|
122
|
+
case "fuzzy":
|
|
123
|
+
return fuzzyMatch(normalized, lookup);
|
|
124
|
+
case "pattern":
|
|
125
|
+
return patternMatch(value, query);
|
|
126
|
+
default:
|
|
127
|
+
return normalized.includes(lookup);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Case- and accent-insensitive regex matching for a single value.
|
|
133
|
+
* See `matchesField` for the contract around the `i`-only flag and the
|
|
134
|
+
* additive accent-fold fallback; an invalid query returns `false`.
|
|
135
|
+
*
|
|
136
|
+
* @param {*} value
|
|
137
|
+
* @param {*} query
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
export function patternMatch(value, query) {
|
|
141
|
+
try {
|
|
142
|
+
const raw = String(value ?? "");
|
|
143
|
+
const source = String(query ?? "");
|
|
144
|
+
const pattern = new RegExp(source, "i");
|
|
145
|
+
const foldedQuery = stripDiacritics(source);
|
|
146
|
+
const foldedPattern = foldedQuery === source ? pattern : new RegExp(foldedQuery, "i");
|
|
147
|
+
return pattern.test(raw) || foldedPattern.test(stripDiacritics(raw));
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Convert any input to a canonical `{ value, label }`.
|
|
155
|
+
*
|
|
156
|
+
* @param {*} raw
|
|
157
|
+
* @param {ItemFields | null} [fields]
|
|
158
|
+
* @returns {ComboboxItem | null}
|
|
159
|
+
*/
|
|
160
|
+
export function toItem(raw, fields = null) {
|
|
161
|
+
if (raw == null) return null;
|
|
162
|
+
if (typeof raw === "string" || typeof raw === "number") {
|
|
163
|
+
return { value: String(raw), label: String(raw) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Real option elements and already-canonical objects are authoritative;
|
|
167
|
+
// labelField/valueField only map plain data objects.
|
|
168
|
+
if (fields && (fields.labelField || fields.valueField) && !hasOwn(raw, "value") && !hasOwn(raw, "label")) {
|
|
169
|
+
const value = (fields.valueField && raw[fields.valueField]) ?? raw.id ?? raw.label ?? "";
|
|
170
|
+
const label = (fields.labelField && raw[fields.labelField]) ?? raw.text ?? raw.value ?? raw.id ?? "";
|
|
171
|
+
return { ...raw, value: String(value ?? ""), label: String(label ?? "") };
|
|
172
|
+
} else {
|
|
173
|
+
const value = raw.value ?? raw.id ?? raw.label ?? "";
|
|
174
|
+
const label = raw.label ?? raw.text ?? raw.value ?? raw.id ?? "";
|
|
175
|
+
return { ...raw, value: String(value ?? ""), label: String(label ?? "") };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Parse the pipe-delimited separator attribute into an array of full
|
|
181
|
+
* separator strings. `null`/empty values yield no separators.
|
|
182
|
+
*
|
|
183
|
+
* @param {string | string[] | null | undefined} raw
|
|
184
|
+
* @returns {string[]}
|
|
185
|
+
*/
|
|
186
|
+
export function parseSeparators(raw) {
|
|
187
|
+
if (Array.isArray(raw)) {
|
|
188
|
+
return raw.map(String).filter((separator) => separator.length > 0);
|
|
189
|
+
}
|
|
190
|
+
if (raw == null) return [];
|
|
191
|
+
return String(raw)
|
|
192
|
+
.split("|")
|
|
193
|
+
.filter((separator) => separator.length > 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Split `input` by the longest matching separators.
|
|
198
|
+
* Returns `{ done: [{ text, sep }], rest }`: `done` holds complete tokens
|
|
199
|
+
* (each terminated by a separator); `rest` is the trailing unterminated
|
|
200
|
+
* text (the incomplete token that must stay in the input).
|
|
201
|
+
*
|
|
202
|
+
* @param {string | null | undefined} input
|
|
203
|
+
* @param {string | string[] | null | undefined} separators
|
|
204
|
+
* @returns {SplitResult}
|
|
205
|
+
*/
|
|
206
|
+
export function splitTokens(input, separators) {
|
|
207
|
+
const result = /** @type {SplitResult} */ ({
|
|
208
|
+
done: /** @type {Token[]} */ ([]),
|
|
209
|
+
rest: String(input ?? ""),
|
|
210
|
+
});
|
|
211
|
+
if (!result.rest) return result;
|
|
212
|
+
|
|
213
|
+
const kinds = parseSeparators(separators).sort((a, b) => b.length - a.length);
|
|
214
|
+
if (!kinds.length) return result;
|
|
215
|
+
|
|
216
|
+
const pattern = new RegExp(`(${kinds.map(escapeRegExp).join("|")})`, "g");
|
|
217
|
+
const parts = result.rest.split(pattern);
|
|
218
|
+
|
|
219
|
+
let buffer = "";
|
|
220
|
+
const done = /** @type {Token[]} */ ([]);
|
|
221
|
+
for (const part of parts) {
|
|
222
|
+
if (kinds.includes(part)) {
|
|
223
|
+
if (buffer) done.push({ text: buffer, sep: part });
|
|
224
|
+
buffer = "";
|
|
225
|
+
} else {
|
|
226
|
+
buffer += part;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
result.done = done;
|
|
231
|
+
result.rest = buffer;
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Rank items by a score function with a stable tiebreak on the original
|
|
237
|
+
* relative order. `score(item, index) => number | false | null`:
|
|
238
|
+
* - `false` and `null` exclude the item (no confidence / explicit exclusion);
|
|
239
|
+
* - `0` is a valid score and keeps the item, ranked last for its tie group.
|
|
240
|
+
*
|
|
241
|
+
* The `index` argument is the position in the input list at scoring time,
|
|
242
|
+
* mirroring how the engine feeds filtered items to the user's scorer.
|
|
243
|
+
*
|
|
244
|
+
* @template T
|
|
245
|
+
* @param {T[]} items
|
|
246
|
+
* @param {(item: T, index: number) => number | false | null} score
|
|
247
|
+
* @returns {T[]}
|
|
248
|
+
*/
|
|
249
|
+
export function rankByScore(items, score) {
|
|
250
|
+
return items
|
|
251
|
+
.map((item, index) => ({ item, index, score: score(item, index) }))
|
|
252
|
+
.filter((entry) => entry.score !== false && entry.score !== null)
|
|
253
|
+
.sort((a, b) => Number(b.score) - Number(a.score) || a.index - b.index)
|
|
254
|
+
.map((entry) => entry.item);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Reconcile the remembered selection order against the currently selected
|
|
259
|
+
* values. Outcome: every selected value that appears in `order` first in the
|
|
260
|
+
* remembered sequence, then any selected value unknown to `order` appended in
|
|
261
|
+
* native `values` order. A remembered value that is no longer selected is
|
|
262
|
+
* never kept. `values` is treated as the source of truth for membership.
|
|
263
|
+
*
|
|
264
|
+
* @template T
|
|
265
|
+
* @param {T[]} values
|
|
266
|
+
* @param {T[]} order
|
|
267
|
+
* @returns {T[]}
|
|
268
|
+
*/
|
|
269
|
+
export function reconcileSelected(values, order) {
|
|
270
|
+
const remaining = new Set(values);
|
|
271
|
+
const result = [];
|
|
272
|
+
for (const value of order) {
|
|
273
|
+
if (remaining.has(value)) {
|
|
274
|
+
result.push(value);
|
|
275
|
+
remaining.delete(value);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
result.push(...remaining);
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Move `identity` within `list` to `index` (clamped to valid bounds). Returns
|
|
284
|
+
* `{ order, from, to }` when a real move happens (a fresh array, the input is
|
|
285
|
+
* never mutated), or `null` when the identity is unknown or already at the
|
|
286
|
+
* target position. `from`/`to` are the pre-move positions.
|
|
287
|
+
*
|
|
288
|
+
* Match is strict identity (SameValueZero), so the list may hold strings or
|
|
289
|
+
* option-element references alike — callers never need string coercion.
|
|
290
|
+
*
|
|
291
|
+
* @template T
|
|
292
|
+
* @param {T[]} list
|
|
293
|
+
* @param {T} identity
|
|
294
|
+
* @param {number} index
|
|
295
|
+
* @returns {{ order: T[], from: number, to: number } | null}
|
|
296
|
+
*/
|
|
297
|
+
export function moveValueInOrder(list, identity, index) {
|
|
298
|
+
const order = [...list];
|
|
299
|
+
const from = order.indexOf(identity);
|
|
300
|
+
if (from < 0) return null;
|
|
301
|
+
const to = Math.max(0, Math.min(Number(index), order.length - 1));
|
|
302
|
+
if (from === to) return null;
|
|
303
|
+
order.splice(to, 0, ...order.splice(from, 1));
|
|
304
|
+
return { order, from, to };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Split a comma-delimited attribute value into a trimmed, non-empty list.
|
|
309
|
+
* `"label, email, "` → `["label", "email"]`; empty input → `[]`.
|
|
310
|
+
*
|
|
311
|
+
* @param {string | null | undefined} raw
|
|
312
|
+
* @returns {string[]}
|
|
313
|
+
*/
|
|
314
|
+
export function parseList(raw) {
|
|
315
|
+
if (raw == null) return [];
|
|
316
|
+
return String(raw)
|
|
317
|
+
.split(",")
|
|
318
|
+
.map((entry) => entry.trim())
|
|
319
|
+
.filter(Boolean);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Boolean `<combo-box>` attribute semantics: absent → `undefined` (so the
|
|
324
|
+
* caller can omit the option and keep the DEFAULTS value), present → `true`
|
|
325
|
+
* unless the authored value is exactly the string `"false"`.
|
|
326
|
+
*
|
|
327
|
+
* @param {Element} element
|
|
328
|
+
* @param {string} name
|
|
329
|
+
* @returns {boolean | undefined}
|
|
330
|
+
*/
|
|
331
|
+
export function booleanAttribute(element, name) {
|
|
332
|
+
if (!element.hasAttribute(name)) return undefined;
|
|
333
|
+
return element.getAttribute(name) !== "false";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Parse a count-like `<combo-box>` attribute. `null` and non-integer values
|
|
338
|
+
* (e.g. `"banana"` or `"2.5"`) yield `undefined` so the caller can omit the
|
|
339
|
+
* option and let the DEFAULTS value apply instead of spreading `NaN`.
|
|
340
|
+
*
|
|
341
|
+
* @param {string | null | undefined} raw
|
|
342
|
+
* @returns {number | undefined}
|
|
343
|
+
*/
|
|
344
|
+
export function parseInteger(raw) {
|
|
345
|
+
if (raw == null) return undefined;
|
|
346
|
+
const number = Number(raw);
|
|
347
|
+
return Number.isInteger(number) ? number : undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Very light subsequence fuzzy match, used by `match: "fuzzy"`.
|
|
352
|
+
*
|
|
353
|
+
* Contract: both `str` and `lookup` must already be normalized (case/accent
|
|
354
|
+
* folding happens in the caller, in `normalize()`) — fuzzyMatch does no
|
|
355
|
+
* normalization of its own. A whitespace-only lookup matches everything; a
|
|
356
|
+
* plain substring still wins via the fast `includes` path; otherwise every
|
|
357
|
+
* non-space character of the lookup must appear in order. The scan advances by
|
|
358
|
+
* `char.length` (not `1`) so a surrogate-pair character is consumed whole.
|
|
359
|
+
*
|
|
360
|
+
* @param {string} str
|
|
361
|
+
* @param {*} lookup
|
|
362
|
+
* @returns {boolean}
|
|
363
|
+
*/
|
|
364
|
+
export function fuzzyMatch(str, lookup) {
|
|
365
|
+
const wanted = String(lookup ?? "");
|
|
366
|
+
if (!wanted.trim()) return true;
|
|
367
|
+
if (str.includes(wanted)) return true;
|
|
368
|
+
|
|
369
|
+
let pos = 0;
|
|
370
|
+
for (const char of wanted) {
|
|
371
|
+
if (char === " ") continue;
|
|
372
|
+
const index = str.indexOf(char, pos);
|
|
373
|
+
if (index === -1) return false;
|
|
374
|
+
pos = index + char.length;
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export { ComboBoxElement, defineCombobox } from "./combo-box.js";
|
|
2
|
+
export { Combobox, default } from "./combobox.js";
|
|
3
|
+
export {
|
|
4
|
+
booleanAttribute,
|
|
5
|
+
fuzzyMatch,
|
|
6
|
+
moveValueInOrder,
|
|
7
|
+
normalize,
|
|
8
|
+
parseInteger,
|
|
9
|
+
parseList,
|
|
10
|
+
parseSeparators,
|
|
11
|
+
rankByScore,
|
|
12
|
+
reconcileSelected,
|
|
13
|
+
splitTokens,
|
|
14
|
+
toItem,
|
|
15
|
+
} from "./helpers.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Public configuration for a Combobox instance.
|
|
19
|
+
* @typedef {import("./combobox.js").ComboboxOptions} ComboboxOptions
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Canonical combobox item (value/label payload plus optional metadata).
|
|
24
|
+
* @typedef {import("./helpers.js").ComboboxItem} ComboboxItem
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A selectable source for a combobox: a free-form input+datalist or a select.
|
|
29
|
+
* @typedef {HTMLInputElement | HTMLSelectElement} ComboboxSource
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Context passed to the async load callback.
|
|
34
|
+
* @typedef {import("./combobox.js").LoadContext} LoadContext
|
|
35
|
+
*/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setDefaultMessages } from "../messages.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* German UI messages.
|
|
5
|
+
* @type {import("../messages.js").Messages}
|
|
6
|
+
*/
|
|
7
|
+
const messages = {
|
|
8
|
+
noResults: "Keine Ergebnisse",
|
|
9
|
+
loading: "Lädt…",
|
|
10
|
+
loadError: "Ergebnisse konnten nicht geladen werden",
|
|
11
|
+
create: (query) => `“${query}” erstellen`,
|
|
12
|
+
position: (label, position, total) => `${label}, Position ${position} von ${total}`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
setDefaultMessages(messages);
|
|
16
|
+
|
|
17
|
+
export default messages;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { setDefaultMessages } from "../messages.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* English UI messages — canonical catalog: keys and default values match the
|
|
5
|
+
* engine defaults, so `en` is the reference every other locale mirrors.
|
|
6
|
+
* @type {import("../messages.js").Messages}
|
|
7
|
+
*/
|
|
8
|
+
const messages = {
|
|
9
|
+
noResults: "No results",
|
|
10
|
+
loading: "Loading…",
|
|
11
|
+
loadError: "Failed to load results",
|
|
12
|
+
create: (query) => `Create “${query}”`,
|
|
13
|
+
position: (label, position, total) => `${label} position ${position} of ${total}`,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
setDefaultMessages(messages);
|
|
17
|
+
|
|
18
|
+
export default messages;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setDefaultMessages } from "../messages.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spanish UI messages.
|
|
5
|
+
* @type {import("../messages.js").Messages}
|
|
6
|
+
*/
|
|
7
|
+
const messages = {
|
|
8
|
+
noResults: "Sin resultados",
|
|
9
|
+
loading: "Cargando…",
|
|
10
|
+
loadError: "No se pudieron cargar los resultados",
|
|
11
|
+
create: (query) => `Crear « ${query} »`,
|
|
12
|
+
position: (label, position, total) => `${label}, posición ${position} de ${total}`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
setDefaultMessages(messages);
|
|
16
|
+
|
|
17
|
+
export default messages;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setDefaultMessages } from "../messages.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* French UI messages.
|
|
5
|
+
* @type {import("../messages.js").Messages}
|
|
6
|
+
*/
|
|
7
|
+
const messages = {
|
|
8
|
+
noResults: "Aucun résultat",
|
|
9
|
+
loading: "Chargement…",
|
|
10
|
+
loadError: "Échec du chargement des résultats",
|
|
11
|
+
create: (query) => `Créer « ${query} »`,
|
|
12
|
+
position: (label, position, total) => `${label}, position ${position} sur ${total}`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
setDefaultMessages(messages);
|
|
16
|
+
|
|
17
|
+
export default messages;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setDefaultMessages } from "../messages.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Italian UI messages.
|
|
5
|
+
* @type {import("../messages.js").Messages}
|
|
6
|
+
*/
|
|
7
|
+
const messages = {
|
|
8
|
+
noResults: "Nessun risultato",
|
|
9
|
+
loading: "Caricamento…",
|
|
10
|
+
loadError: "Impossibile caricare i risultati",
|
|
11
|
+
create: (query) => `Crea “${query}”`,
|
|
12
|
+
position: (label, position, total) => `${label}, posizione ${position} di ${total}`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
setDefaultMessages(messages);
|
|
16
|
+
|
|
17
|
+
export default messages;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setDefaultMessages } from "../messages.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dutch UI messages.
|
|
5
|
+
* @type {import("../messages.js").Messages}
|
|
6
|
+
*/
|
|
7
|
+
const messages = {
|
|
8
|
+
noResults: "Geen resultaten",
|
|
9
|
+
loading: "Laden…",
|
|
10
|
+
loadError: "Resultaten laden mislukt",
|
|
11
|
+
create: (query) => `Maak “${query}” aan`,
|
|
12
|
+
position: (label, position, total) => `${label}, positie ${position} van ${total}`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
setDefaultMessages(messages);
|
|
16
|
+
|
|
17
|
+
export default messages;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setDefaultMessages } from "../messages.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portuguese (European) UI messages.
|
|
5
|
+
* @type {import("../messages.js").Messages}
|
|
6
|
+
*/
|
|
7
|
+
const messages = {
|
|
8
|
+
noResults: "Sem resultados",
|
|
9
|
+
loading: "A carregar…",
|
|
10
|
+
loadError: "Falha ao carregar os resultados",
|
|
11
|
+
create: (query) => `Criar “${query}”`,
|
|
12
|
+
position: (label, position, total) => `${label}, posição ${position} de ${total}`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
setDefaultMessages(messages);
|
|
16
|
+
|
|
17
|
+
export default messages;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setDefaultMessages } from "../messages.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Russian UI messages.
|
|
5
|
+
* @type {import("../messages.js").Messages}
|
|
6
|
+
*/
|
|
7
|
+
const messages = {
|
|
8
|
+
noResults: "Ничего не найдено",
|
|
9
|
+
loading: "Загрузка…",
|
|
10
|
+
loadError: "Не удалось загрузить результаты",
|
|
11
|
+
create: (query) => `Создать « ${query} »`,
|
|
12
|
+
position: (label, position, total) => `${label}: позиция ${position} из ${total}`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
setDefaultMessages(messages);
|
|
16
|
+
|
|
17
|
+
export default messages;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setDefaultMessages } from "../messages.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Simplified Chinese UI messages.
|
|
5
|
+
* @type {import("../messages.js").Messages}
|
|
6
|
+
*/
|
|
7
|
+
const messages = {
|
|
8
|
+
noResults: "没有结果",
|
|
9
|
+
loading: "加载中…",
|
|
10
|
+
loadError: "加载结果失败",
|
|
11
|
+
create: (query) => `创建“ ${query} ”`,
|
|
12
|
+
position: (label, position, total) => `${label},第 ${position} 项,共 ${total} 项`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
setDefaultMessages(messages);
|
|
16
|
+
|
|
17
|
+
export default messages;
|
package/src/messages.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated UI text, centralized for i18n. This module is DOM-free so the
|
|
3
|
+
* catalog can be imported and unit-tested without a browser; the engine's
|
|
4
|
+
* `Combobox.getDefaultMessages()` / `setDefaultMessages()` statics delegate to
|
|
5
|
+
* it, and the shipped `src/locales/*` modules apply their translations through
|
|
6
|
+
* `setDefaultMessages` on import.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Screen-reader / UI status messages. Defaults are merged so the label
|
|
11
|
+
* producers (`create`, `position`) are always functions; the plain status
|
|
12
|
+
* strings (`noResults`, `loading`, `loadError`) are literal text rendered by
|
|
13
|
+
* `setContent` (rich DOM rows use the `render.*` hooks instead).
|
|
14
|
+
* @typedef {Object} Messages
|
|
15
|
+
* @property {string} [noResults]
|
|
16
|
+
* @property {string} [loading]
|
|
17
|
+
* @property {string} [loadError]
|
|
18
|
+
* @property {(query: string, context?: import("./combobox.js").ComboboxContext) => string} [create]
|
|
19
|
+
* @property {(label: string, position: number, total: number, context?: import("./combobox.js").ComboboxContext) => string} [position]
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** @type {Messages} */
|
|
23
|
+
const DEFAULT_MESSAGES = {
|
|
24
|
+
noResults: "No results",
|
|
25
|
+
loading: "Loading…",
|
|
26
|
+
loadError: "Failed to load results",
|
|
27
|
+
create: (query) => `Create “${query}”`,
|
|
28
|
+
position: (label, position, total) => `${label} position ${position} of ${total}`,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read the current default UI messages. Returns a shallow copy; mutating
|
|
33
|
+
* the result does not affect the engine.
|
|
34
|
+
* @returns {Messages}
|
|
35
|
+
*/
|
|
36
|
+
export function getDefaultMessages() {
|
|
37
|
+
return { ...DEFAULT_MESSAGES };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Merge application or locale-provided UI text into the default messages.
|
|
42
|
+
* Called by the shipped `locales/*` modules on import. Only comboboxes
|
|
43
|
+
* created *after* this call see the new text: instances resolve their
|
|
44
|
+
* messages as a snapshot at construction time. Per-instance `messages`
|
|
45
|
+
* options always take precedence over these defaults. Missing keys keep
|
|
46
|
+
* their current translation, and producer keys (`create`, `position`) stay
|
|
47
|
+
* functions.
|
|
48
|
+
* @param {Partial<Messages>} messages
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
51
|
+
export function setDefaultMessages(messages) {
|
|
52
|
+
Object.assign(DEFAULT_MESSAGES, messages);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { DEFAULT_MESSAGES };
|