@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
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;
@@ -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 };