@samline/autocomplete 1.0.0 → 2.0.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 (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE.md +1 -1
  3. package/README.md +208 -32
  4. package/dist/browser/global.global.js +505 -274
  5. package/dist/browser/global.global.js.map +1 -1
  6. package/dist/browser/index.cjs +663 -0
  7. package/dist/browser/index.cjs.map +1 -0
  8. package/dist/browser/index.d.cts +31 -0
  9. package/dist/browser/index.d.ts +31 -0
  10. package/dist/browser/index.js +24 -0
  11. package/dist/browser/index.js.map +1 -0
  12. package/dist/chunk-4IPBCQDZ.js +218 -0
  13. package/dist/chunk-4IPBCQDZ.js.map +1 -0
  14. package/dist/chunk-5UZVFHB4.js +70 -0
  15. package/dist/chunk-5UZVFHB4.js.map +1 -0
  16. package/dist/chunk-AD7T42HJ.js +1 -0
  17. package/dist/chunk-AD7T42HJ.js.map +1 -0
  18. package/dist/chunk-PPDQPLPE.js +1 -0
  19. package/dist/chunk-PPDQPLPE.js.map +1 -0
  20. package/dist/chunk-U5JYDGKW.js +403 -0
  21. package/dist/chunk-U5JYDGKW.js.map +1 -0
  22. package/dist/core/index.cjs +181 -0
  23. package/dist/core/index.cjs.map +1 -0
  24. package/dist/core/index.d.cts +31 -0
  25. package/dist/core/index.d.ts +31 -0
  26. package/dist/core/index.js +22 -0
  27. package/dist/core/index.js.map +1 -0
  28. package/dist/index.cjs +527 -274
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +20 -69
  31. package/dist/index.d.ts +20 -69
  32. package/dist/index.js +54 -405
  33. package/dist/index.js.map +1 -1
  34. package/dist/render-B38UXYCT.d.ts +7 -0
  35. package/dist/render-WD69VMUs.d.cts +7 -0
  36. package/dist/styles.css +19 -9
  37. package/dist/types-CPvjhqjb.d.cts +118 -0
  38. package/dist/types-CPvjhqjb.d.ts +118 -0
  39. package/dist/vanilla/index.cjs +598 -0
  40. package/dist/vanilla/index.cjs.map +1 -0
  41. package/dist/vanilla/index.d.cts +11 -0
  42. package/dist/vanilla/index.d.ts +11 -0
  43. package/dist/vanilla/index.js +13 -0
  44. package/dist/vanilla/index.js.map +1 -0
  45. package/package.json +19 -4
  46. package/dist/browser/global.d.ts +0 -61
package/dist/index.cjs CHANGED
@@ -20,22 +20,83 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
+ Autocomplete: () => Autocomplete,
24
+ DEFAULT_CACHE_SIZE: () => DEFAULT_CACHE_SIZE,
25
+ DEFAULT_CACHE_TTL: () => DEFAULT_CACHE_TTL,
26
+ DEFAULT_DEBOUNCE: () => DEFAULT_DEBOUNCE,
27
+ DEFAULT_MAX_RESULTS: () => DEFAULT_MAX_RESULTS,
28
+ DEFAULT_MESSAGES: () => DEFAULT_MESSAGES,
29
+ DEFAULT_MIN_LENGTH: () => DEFAULT_MIN_LENGTH,
30
+ DEFAULT_QUERY_PARAM: () => DEFAULT_QUERY_PARAM,
31
+ HIGHLIGHT_ATTRIBUTE: () => HIGHLIGHT_ATTRIBUTE,
32
+ LIST_ATTRIBUTE: () => LIST_ATTRIBUTE,
33
+ LIVE_ATTRIBUTE: () => LIVE_ATTRIBUTE,
34
+ OPTION_ATTRIBUTE: () => OPTION_ATTRIBUTE,
35
+ POPUP_ATTRIBUTE: () => POPUP_ATTRIBUTE,
36
+ STATUS_ATTRIBUTE: () => STATUS_ATTRIBUTE,
23
37
  autocomplete: () => autocomplete,
24
38
  browser: () => browser,
25
39
  createAutocomplete: () => createAutocomplete,
26
- createAutocompleteController: () => createAutocompleteController,
40
+ createCache: () => createCache,
41
+ filterSuggestions: () => filterSuggestions,
42
+ fromEndpoint: () => fromEndpoint,
43
+ highlight: () => highlight,
27
44
  mapCommonResponse: () => mapCommonResponse,
28
- parseJsonResponse: () => parseJsonResponse
45
+ normalizeText: () => normalizeText,
46
+ parseJsonResponse: () => parseJsonResponse,
47
+ renderHighlight: () => renderHighlight
29
48
  });
30
49
  module.exports = __toCommonJS(src_exports);
31
50
 
32
- // src/core/defaults.ts
33
- var DEFAULT_MESSAGES = {
34
- loading: "Searching...",
35
- empty: "No results",
36
- error: "Search failed"
51
+ // src/constants.ts
52
+ var DEFAULT_QUERY_PARAM = "q";
53
+ var DEFAULT_MIN_LENGTH = 2;
54
+ var DEFAULT_DEBOUNCE = 250;
55
+ var DEFAULT_MAX_RESULTS = 8;
56
+ var DEFAULT_CACHE_SIZE = 50;
57
+ var DEFAULT_CACHE_TTL = 3e5;
58
+ var POPUP_ATTRIBUTE = "data-autocomplete-popup";
59
+ var LIST_ATTRIBUTE = "data-autocomplete-list";
60
+ var OPTION_ATTRIBUTE = "data-autocomplete-option";
61
+ var STATUS_ATTRIBUTE = "data-autocomplete-status";
62
+ var LIVE_ATTRIBUTE = "data-autocomplete-live";
63
+ var HIGHLIGHT_ATTRIBUTE = "data-autocomplete-highlight";
64
+
65
+ // src/core/cache.ts
66
+ var createCache = ({ max = DEFAULT_CACHE_SIZE, ttl = DEFAULT_CACHE_TTL } = {}, now = Date.now) => {
67
+ const entries = /* @__PURE__ */ new Map();
68
+ const get = (key) => {
69
+ const entry = entries.get(key);
70
+ if (!entry) return void 0;
71
+ entries.delete(key);
72
+ if (entry.expires <= now()) return void 0;
73
+ entries.set(key, entry);
74
+ return entry.value;
75
+ };
76
+ const set = (key, value) => {
77
+ entries.delete(key);
78
+ entries.set(key, { value, expires: now() + ttl });
79
+ while (entries.size > max) {
80
+ const oldest = entries.keys().next();
81
+ if (oldest.done) break;
82
+ entries.delete(oldest.value);
83
+ }
84
+ };
85
+ return {
86
+ get,
87
+ set,
88
+ clear: () => entries.clear(),
89
+ get size() {
90
+ return entries.size;
91
+ }
92
+ };
37
93
  };
94
+
95
+ // src/core/source.ts
38
96
  var parseJsonResponse = async (response) => response.json();
97
+ var invalidPayloadError = () => new TypeError(
98
+ "Autocomplete response does not contain an array. Configure mapResponse(payload, response, query)."
99
+ );
39
100
  var mapCommonResponse = (payload) => {
40
101
  if (Array.isArray(payload)) return payload;
41
102
  if (!payload || typeof payload !== "object") throw invalidPayloadError();
@@ -45,86 +106,200 @@ var mapCommonResponse = (payload) => {
45
106
  if (Array.isArray(record.data)) return record.data;
46
107
  throw invalidPayloadError();
47
108
  };
48
- function getDefaultLabel(suggestion) {
49
- if (typeof suggestion === "string") return suggestion;
50
- if (!suggestion || typeof suggestion !== "object") return String(suggestion ?? "");
51
- const record = suggestion;
52
- return String(record.label ?? record.value ?? record.title ?? "");
53
- }
54
- function getDefaultValue(suggestion) {
109
+ var fromEndpoint = ({
110
+ endpoint,
111
+ queryParam = DEFAULT_QUERY_PARAM,
112
+ requestInit = {},
113
+ parseResponse = parseJsonResponse,
114
+ mapResponse = mapCommonResponse
115
+ }) => {
116
+ if (!endpoint) throw new TypeError("Autocomplete requires an endpoint.");
117
+ return async (query, { signal, input }) => {
118
+ const resolved = typeof endpoint === "function" ? endpoint(query, input) : endpoint;
119
+ const url = new URL(resolved, document.baseURI);
120
+ if (queryParam !== null) url.searchParams.set(queryParam, query);
121
+ const init = typeof requestInit === "function" ? requestInit(query, input) : requestInit;
122
+ const response = await fetch(url, { ...init, signal });
123
+ if (!response.ok) throw new Error(`Autocomplete request failed with HTTP ${response.status}.`);
124
+ const payload = await parseResponse(response, query);
125
+ const result = await mapResponse(payload, response, query);
126
+ if (!Array.isArray(result))
127
+ throw new TypeError("Autocomplete mapResponse must return an array.");
128
+ return result;
129
+ };
130
+ };
131
+
132
+ // src/core/state.ts
133
+ var DEFAULT_MESSAGES = {
134
+ loading: "Searching...",
135
+ empty: "No results",
136
+ error: "Search failed",
137
+ results: ({ count }) => count === 1 ? "1 result available" : `${count} results available`
138
+ };
139
+ var resolveMessage = (message, context) => typeof message === "function" ? message(context) : message;
140
+ var readField = (suggestion, keys) => {
55
141
  if (typeof suggestion === "string") return suggestion;
56
142
  if (!suggestion || typeof suggestion !== "object") return String(suggestion ?? "");
57
143
  const record = suggestion;
58
- return String(record.value ?? record.label ?? record.title ?? "");
59
- }
60
- function getDefaultId(suggestion, index) {
144
+ const key = keys.find((name) => record[name] !== void 0 && record[name] !== null);
145
+ return key === void 0 ? "" : String(record[key]);
146
+ };
147
+ var getDefaultLabel = (suggestion) => readField(suggestion, ["label", "value", "title", "name"]);
148
+ var getDefaultValue = (suggestion) => readField(suggestion, ["value", "label", "title", "name"]);
149
+ var getDefaultId = (suggestion, index) => {
61
150
  if (!suggestion || typeof suggestion !== "object") return index;
62
151
  return suggestion.id ?? index;
63
- }
64
- function validateOptions(options) {
65
- if (!options.endpoint) throw new TypeError("Autocomplete requires an endpoint.");
152
+ };
153
+ var assertNonNegativeInteger = (value, name) => {
154
+ if (value !== void 0 && (!Number.isInteger(value) || value < 0)) {
155
+ throw new TypeError(`Autocomplete ${name} must be a non-negative integer.`);
156
+ }
157
+ };
158
+ var assertPositiveInteger = (value, name) => {
159
+ if (value !== void 0 && (!Number.isInteger(value) || value < 1)) {
160
+ throw new TypeError(`Autocomplete ${name} must be an integer greater than zero.`);
161
+ }
162
+ };
163
+ var validateOptions = (options) => {
164
+ const hasSource = options.source !== void 0;
165
+ const hasEndpoint = options.endpoint !== void 0;
166
+ if (hasSource && hasEndpoint) {
167
+ throw new TypeError("Autocomplete accepts either a source or an endpoint, not both.");
168
+ }
169
+ if (!hasSource && !options.endpoint) {
170
+ throw new TypeError("Autocomplete requires a source or an endpoint.");
171
+ }
172
+ if (hasSource && !Array.isArray(options.source) && typeof options.source !== "function") {
173
+ throw new TypeError("Autocomplete source must be an array or a function.");
174
+ }
66
175
  assertNonNegativeInteger(options.minLength, "minLength");
67
176
  assertNonNegativeInteger(options.debounce, "debounce");
68
- if (options.maxResults !== void 0 && (!Number.isInteger(options.maxResults) || options.maxResults < 1)) {
69
- throw new TypeError("Autocomplete maxResults must be an integer greater than zero.");
177
+ assertPositiveInteger(options.maxResults, "maxResults");
178
+ if (typeof options.cache === "object") {
179
+ assertPositiveInteger(options.cache.max, "cache.max");
180
+ assertNonNegativeInteger(options.cache.ttl, "cache.ttl");
70
181
  }
71
- }
72
- function assertNonNegativeInteger(value, name) {
73
- if (value !== void 0 && (!Number.isInteger(value) || value < 0)) {
74
- throw new TypeError(`Autocomplete ${name} must be a non-negative integer.`);
182
+ };
183
+
184
+ // src/core/text.ts
185
+ var COMBINING_MARKS = /\p{M}/gu;
186
+ var WHITESPACE = /\s+/;
187
+ var normalizeText = (value) => value.normalize("NFD").replace(COMBINING_MARKS, "").toLowerCase();
188
+ var getTerms = (query, normalize) => Array.from(new Set(normalize(query).split(WHITESPACE).filter(Boolean)));
189
+ var filterSuggestions = (suggestions, query, { getText, normalize = normalizeText }) => {
190
+ const terms = getTerms(query, normalize);
191
+ if (terms.length === 0) return [...suggestions];
192
+ return suggestions.filter((suggestion) => {
193
+ const text = normalize(getText(suggestion));
194
+ return terms.every((term) => text.includes(term));
195
+ });
196
+ };
197
+ var highlight = (text, query, normalize = normalizeText) => {
198
+ const terms = getTerms(query, normalize);
199
+ if (terms.length === 0 || text === "") return text === "" ? [] : [{ text, match: false }];
200
+ let normalized = "";
201
+ const starts = [];
202
+ const ends = [];
203
+ let offset = 0;
204
+ for (const char of text) {
205
+ const piece = normalize(char);
206
+ if (piece === "" && ends.length > 0) ends[ends.length - 1] = offset + char.length;
207
+ normalized += piece;
208
+ for (let index = 0; index < piece.length; index += 1) {
209
+ starts.push(offset);
210
+ ends.push(offset + char.length);
211
+ }
212
+ offset += char.length;
75
213
  }
76
- }
77
- function invalidPayloadError() {
78
- return new TypeError(
79
- "Autocomplete response does not contain an array. Configure mapResponse(payload, response, query)."
80
- );
81
- }
214
+ const ranges = [];
215
+ for (const term of terms) {
216
+ let found = normalized.indexOf(term);
217
+ while (found !== -1) {
218
+ ranges.push([starts[found], ends[found + term.length - 1]]);
219
+ found = normalized.indexOf(term, found + 1);
220
+ }
221
+ }
222
+ if (ranges.length === 0) return [{ text, match: false }];
223
+ ranges.sort((a, b) => a[0] - b[0]);
224
+ const merged = [];
225
+ for (const range of ranges) {
226
+ const last = merged[merged.length - 1];
227
+ if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
228
+ else merged.push([range[0], range[1]]);
229
+ }
230
+ const parts = [];
231
+ let cursor = 0;
232
+ for (const [start, end] of merged) {
233
+ if (start > cursor) parts.push({ text: text.slice(cursor, start), match: false });
234
+ parts.push({ text: text.slice(start, end), match: true });
235
+ cursor = end;
236
+ }
237
+ if (cursor < text.length) parts.push({ text: text.slice(cursor), match: false });
238
+ return parts;
239
+ };
82
240
 
83
- // src/core/dom.ts
84
- function resolveInput(target) {
241
+ // src/vanilla/dom.ts
242
+ var resolveInput = (target) => {
85
243
  const element = typeof target === "string" ? document.querySelector(target) : target;
86
244
  if (!(element instanceof HTMLInputElement)) {
87
245
  throw new TypeError("Autocomplete requires an HTMLInputElement or a valid selector.");
88
246
  }
89
247
  return element;
90
- }
91
- function sanitizeId(value) {
92
- return String(value).replace(/[^a-zA-Z0-9_-]/g, "-");
93
- }
94
- function restoreAttribute(element, name, value) {
95
- if (value === null) {
96
- element.removeAttribute(name);
97
- return;
248
+ };
249
+ var sanitizeId = (value) => String(value).replace(/[^a-zA-Z0-9_-]/g, "-");
250
+ var restoreAttribute = (element, name, value) => {
251
+ if (value === null) element.removeAttribute(name);
252
+ else element.setAttribute(name, value);
253
+ };
254
+ var renderHighlight = (element, parts) => {
255
+ for (const part of parts) {
256
+ if (!part.match) {
257
+ element.append(document.createTextNode(part.text));
258
+ continue;
259
+ }
260
+ const mark = document.createElement("mark");
261
+ mark.setAttribute(HIGHLIGHT_ATTRIBUTE, "");
262
+ mark.textContent = part.text;
263
+ element.append(mark);
98
264
  }
99
- element.setAttribute(name, value);
100
- }
265
+ };
266
+ var VISUALLY_HIDDEN = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0";
101
267
 
102
- // src/core/controller.ts
268
+ // src/vanilla/render.ts
269
+ var RESTORED_ATTRIBUTES = [
270
+ "role",
271
+ "autocomplete",
272
+ "aria-autocomplete",
273
+ "aria-controls",
274
+ "aria-expanded",
275
+ "aria-activedescendant"
276
+ ];
103
277
  var instanceCount = 0;
104
- function createAutocompleteController(target, options) {
278
+ var isAbortError = (error) => error instanceof DOMException && error.name === "AbortError";
279
+ var autocomplete = (target, options) => {
105
280
  validateOptions(options);
106
281
  const input = resolveInput(target);
107
282
  const instanceId = `autocomplete-${++instanceCount}`;
108
283
  const messages = { ...DEFAULT_MESSAGES, ...options.messages };
109
- const mapResponse = options.mapResponse ?? mapCommonResponse;
110
- const settings = {
111
- queryParam: options.queryParam === void 0 ? "q" : options.queryParam,
112
- requestInit: options.requestInit ?? {},
113
- parseResponse: options.parseResponse ?? parseJsonResponse,
114
- mapResponse,
115
- getId: options.getId ?? getDefaultId,
116
- getLabel: options.getLabel ?? getDefaultLabel,
117
- getValue: options.getValue ?? getDefaultValue,
118
- renderOption: options.renderOption,
119
- minLength: options.minLength ?? 2,
120
- debounce: options.debounce ?? 250,
121
- maxResults: options.maxResults ?? 8,
122
- searchOnFocus: options.searchOnFocus ?? true,
123
- onSelect: options.onSelect,
124
- onError: options.onError
125
- };
284
+ const localSuggestions = Array.isArray(options.source) ? options.source : null;
285
+ const getLabel = options.getLabel ?? getDefaultLabel;
286
+ const getValue = options.getValue ?? getDefaultValue;
287
+ const getId = options.getId ?? getDefaultId;
288
+ const normalize = options.normalize ?? normalizeText;
289
+ const minLength = options.minLength ?? (localSuggestions ? 1 : DEFAULT_MIN_LENGTH);
290
+ const debounce = options.debounce ?? (localSuggestions ? 0 : DEFAULT_DEBOUNCE);
291
+ const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
292
+ const searchOnFocus = options.searchOnFocus ?? true;
293
+ const highlightEnabled = options.highlight ?? true;
294
+ const source = localSuggestions ? null : typeof options.source === "function" ? options.source : fromEndpoint(options);
295
+ const cache = source && options.cache !== false ? createCache(typeof options.cache === "object" ? options.cache : {}) : null;
296
+ const popup = document.createElement("div");
126
297
  const list = document.createElement("div");
298
+ const status = document.createElement("div");
299
+ const live = document.createElement("div");
127
300
  const originalAttributes = /* @__PURE__ */ new Map();
301
+ const label = input.labels?.[0] ?? null;
302
+ const originalLabelId = label?.getAttribute("id") ?? null;
128
303
  let suggestions = [];
129
304
  let activeIndex = -1;
130
305
  let selected = null;
@@ -133,145 +308,220 @@ function createAutocompleteController(target, options) {
133
308
  let requestSequence = 0;
134
309
  let destroyed = false;
135
310
  let suppressFocusSearch = false;
311
+ popup.hidden = true;
312
+ popup.setAttribute(POPUP_ATTRIBUTE, "");
136
313
  list.id = `${instanceId}-listbox`;
137
314
  list.hidden = true;
138
315
  list.role = "listbox";
139
- list.dataset.autocompleteList = "";
140
- for (const name of [
141
- "role",
142
- "autocomplete",
143
- "aria-autocomplete",
144
- "aria-controls",
145
- "aria-expanded",
146
- "aria-activedescendant"
147
- ]) {
148
- originalAttributes.set(name, input.getAttribute(name));
316
+ list.setAttribute(LIST_ATTRIBUTE, "");
317
+ status.hidden = true;
318
+ status.setAttribute(STATUS_ATTRIBUTE, "");
319
+ status.setAttribute("aria-hidden", "true");
320
+ live.role = "status";
321
+ live.setAttribute("aria-live", "polite");
322
+ live.setAttribute("aria-atomic", "true");
323
+ live.setAttribute(LIVE_ATTRIBUTE, "");
324
+ live.style.cssText = VISUALLY_HIDDEN;
325
+ if (label) {
326
+ if (!label.id) label.id = `${instanceId}-label`;
327
+ list.setAttribute("aria-labelledby", label.id);
149
328
  }
329
+ for (const name of RESTORED_ATTRIBUTES) originalAttributes.set(name, input.getAttribute(name));
150
330
  input.role = "combobox";
151
331
  input.autocomplete = "off";
152
332
  input.setAttribute("aria-autocomplete", "list");
153
333
  input.setAttribute("aria-controls", list.id);
154
334
  input.setAttribute("aria-expanded", "false");
155
- input.insertAdjacentElement("afterend", list);
156
- function buildUrl(query) {
157
- const endpoint = typeof options.endpoint === "function" ? options.endpoint(query, input) : options.endpoint;
158
- const url = new URL(endpoint, document.baseURI);
159
- if (settings.queryParam !== null) url.searchParams.set(settings.queryParam, query);
160
- return url;
161
- }
162
- function buildRequestInit(query, signal) {
163
- const init = typeof settings.requestInit === "function" ? settings.requestInit(query, input) : settings.requestInit;
164
- return { ...init, signal };
165
- }
166
- function handleInput(event) {
167
- if (event.isComposing) return;
168
- scheduleSearch();
169
- }
170
- function scheduleSearch() {
171
- window.clearTimeout(debounceTimer);
172
- const query = input.value.trim();
173
- if (selected !== null) {
174
- selected = null;
175
- dispatch("autocomplete:clear", { query });
176
- }
177
- if (query.length < settings.minLength) {
178
- cancel();
179
- close();
335
+ popup.append(list, status);
336
+ input.insertAdjacentElement("afterend", popup);
337
+ popup.insertAdjacentElement("afterend", live);
338
+ const dispatch = (name, detail) => {
339
+ input.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
340
+ };
341
+ const announce = (message) => {
342
+ live.textContent = message;
343
+ };
344
+ const cancel = () => {
345
+ clearTimeout(debounceTimer);
346
+ debounceTimer = void 0;
347
+ requestSequence += 1;
348
+ abortController?.abort();
349
+ abortController = null;
350
+ };
351
+ const updateActiveOption = () => {
352
+ const elements = list.querySelectorAll(`[${OPTION_ATTRIBUTE}]`);
353
+ elements.forEach((element, index) => {
354
+ const active2 = index === activeIndex;
355
+ element.dataset.active = String(active2);
356
+ element.setAttribute("aria-selected", String(active2));
357
+ });
358
+ const active = elements.item(activeIndex);
359
+ if (!active) {
360
+ input.removeAttribute("aria-activedescendant");
180
361
  return;
181
362
  }
182
- debounceTimer = window.setTimeout(() => void search(query), settings.debounce);
183
- }
184
- async function search(query = input.value.trim()) {
185
- if (destroyed || query.length < settings.minLength) {
363
+ input.setAttribute("aria-activedescendant", active.id);
364
+ active.scrollIntoView({ block: "nearest" });
365
+ };
366
+ const close = () => {
367
+ activeIndex = -1;
368
+ popup.hidden = true;
369
+ list.hidden = true;
370
+ status.hidden = true;
371
+ input.setAttribute("aria-expanded", "false");
372
+ input.removeAttribute("aria-activedescendant");
373
+ updateActiveOption();
374
+ };
375
+ const dismiss = () => {
376
+ cancel();
377
+ close();
378
+ };
379
+ const renderStatus = (state, query) => {
380
+ if (destroyed) return;
381
+ const message = resolveMessage(messages[state], { query, count: 0 });
382
+ list.replaceChildren();
383
+ list.hidden = true;
384
+ status.textContent = message;
385
+ status.setAttribute(STATUS_ATTRIBUTE, state);
386
+ status.hidden = false;
387
+ popup.hidden = false;
388
+ input.setAttribute("aria-expanded", "false");
389
+ input.removeAttribute("aria-activedescendant");
390
+ if (state !== "loading") announce(message);
391
+ };
392
+ const select = (index) => {
393
+ const suggestion = suggestions[index];
394
+ if (!suggestion) return;
395
+ selected = suggestion.data;
396
+ input.value = suggestion.value;
397
+ dismiss();
398
+ dispatch("autocomplete:select", {
399
+ suggestion: suggestion.data,
400
+ label: suggestion.label,
401
+ value: suggestion.value
402
+ });
403
+ options.onSelect?.(suggestion.data, input);
404
+ };
405
+ const handleOptionPointerDown = (event) => {
406
+ event.preventDefault();
407
+ select(Number(event.currentTarget.dataset.index));
408
+ if (document.activeElement !== input) {
409
+ suppressFocusSearch = true;
410
+ input.focus({ preventScroll: true });
411
+ suppressFocusSearch = false;
412
+ }
413
+ };
414
+ const handleOptionPointerEnter = (event) => {
415
+ activeIndex = Number(event.currentTarget.dataset.index);
416
+ updateActiveOption();
417
+ };
418
+ const renderSuggestions = (query) => {
419
+ const fragment = document.createDocumentFragment();
420
+ suggestions.forEach((suggestion, index) => {
421
+ const option = document.createElement("div");
422
+ const parts = highlightEnabled ? highlight(suggestion.label, query, normalize) : [{ text: suggestion.label, match: false }];
423
+ option.id = suggestion.id;
424
+ option.role = "option";
425
+ option.setAttribute(OPTION_ATTRIBUTE, "");
426
+ option.dataset.index = String(index);
427
+ option.setAttribute("aria-selected", "false");
428
+ renderHighlight(option, parts);
429
+ options.renderOption?.(option, suggestion.data, {
430
+ query,
431
+ index,
432
+ label: suggestion.label,
433
+ highlight: parts
434
+ });
435
+ option.addEventListener("pointerdown", handleOptionPointerDown);
436
+ option.addEventListener("pointerenter", handleOptionPointerEnter);
437
+ fragment.append(option);
438
+ });
439
+ list.replaceChildren(fragment);
440
+ list.hidden = false;
441
+ status.hidden = true;
442
+ popup.hidden = false;
443
+ input.setAttribute("aria-expanded", "true");
444
+ };
445
+ const normalizeSuggestion = (suggestion, index) => ({
446
+ id: `${instanceId}-option-${sanitizeId(getId(suggestion, index))}`,
447
+ label: String(getLabel(suggestion)),
448
+ value: String(getValue(suggestion)),
449
+ data: suggestion
450
+ });
451
+ const showResults = (query, result, cached) => {
452
+ if (destroyed) return;
453
+ suggestions = result.slice(0, maxResults).map(normalizeSuggestion);
454
+ activeIndex = -1;
455
+ if (suggestions.length === 0) renderStatus("empty", query);
456
+ else {
457
+ renderSuggestions(query);
458
+ announce(resolveMessage(messages.results, { query, count: suggestions.length }));
459
+ }
460
+ dispatch("autocomplete:load", {
461
+ query,
462
+ suggestions: suggestions.map((suggestion) => suggestion.data),
463
+ cached
464
+ });
465
+ };
466
+ const filterLocal = (items, query) => {
467
+ const filter = options.filter;
468
+ return filter ? items.filter((item) => filter(item, query)) : filterSuggestions(items, query, { getText: getLabel, normalize });
469
+ };
470
+ const search = async (query = input.value.trim()) => {
471
+ if (destroyed || query.length < minLength) {
186
472
  close();
187
473
  return;
188
474
  }
189
475
  cancel();
190
476
  const sequence = ++requestSequence;
477
+ if (localSuggestions) {
478
+ showResults(query, filterLocal(localSuggestions, query), false);
479
+ return;
480
+ }
481
+ const hit = cache?.get(query);
482
+ if (hit) {
483
+ showResults(query, hit, true);
484
+ return;
485
+ }
191
486
  const controller = new AbortController();
192
487
  abortController = controller;
193
- renderStatus(messages.loading, "status", "loading");
488
+ renderStatus("loading", query);
194
489
  dispatch("autocomplete:loadstart", { query });
195
490
  try {
196
- const response = await fetch(buildUrl(query), buildRequestInit(query, controller.signal));
197
- if (!response.ok) throw new Error(`Autocomplete request failed with HTTP ${response.status}.`);
198
- const payload = await settings.parseResponse(response, query);
199
- const result = await settings.mapResponse(payload, response, query);
200
- if (!Array.isArray(result))
201
- throw new TypeError("Autocomplete mapResponse must return an array.");
491
+ const result = await source(query, { signal: controller.signal, input });
492
+ if (!Array.isArray(result)) throw new TypeError("Autocomplete source must return an array.");
202
493
  if (sequence !== requestSequence) return;
203
- suggestions = result.slice(0, settings.maxResults).map(normalizeSuggestion);
204
- activeIndex = -1;
205
- if (suggestions.length === 0) renderStatus(messages.empty, "status", "empty");
206
- else renderSuggestions();
207
- dispatch("autocomplete:load", {
208
- query,
209
- suggestions: suggestions.map((suggestion) => suggestion.data)
210
- });
494
+ cache?.set(query, result);
495
+ showResults(query, result, false);
211
496
  } catch (error) {
212
- if (isAbortError(error) || sequence !== requestSequence) return;
497
+ if (isAbortError(error) || sequence !== requestSequence || destroyed) return;
213
498
  suggestions = [];
214
499
  activeIndex = -1;
215
- renderStatus(messages.error, "alert", "error");
500
+ renderStatus("error", query);
216
501
  dispatch("autocomplete:error", { query, error });
217
- settings.onError?.(error, input);
502
+ options.onError?.(error, input);
218
503
  } finally {
219
504
  if (sequence === requestSequence) abortController = null;
220
505
  }
221
- }
222
- function normalizeSuggestion(suggestion, index) {
223
- return {
224
- id: `${instanceId}-option-${sanitizeId(settings.getId(suggestion, index))}`,
225
- label: String(settings.getLabel(suggestion)),
226
- value: String(settings.getValue(suggestion)),
227
- data: suggestion
228
- };
229
- }
230
- function renderSuggestions() {
231
- const fragment = document.createDocumentFragment();
232
- list.replaceChildren();
233
- suggestions.forEach((suggestion, index) => {
234
- const option = document.createElement("button");
235
- option.id = suggestion.id;
236
- option.type = "button";
237
- option.role = "option";
238
- option.textContent = suggestion.label;
239
- option.dataset.autocompleteOption = "";
240
- option.dataset.index = String(index);
241
- option.setAttribute("aria-selected", "false");
242
- settings.renderOption?.(option, suggestion.data);
243
- option.addEventListener("pointerdown", handleOptionPointerDown);
244
- option.addEventListener("pointerenter", handleOptionPointerEnter);
245
- fragment.append(option);
246
- });
247
- list.append(fragment);
248
- open();
249
- }
250
- function renderStatus(message, role, state) {
251
- const element = document.createElement("div");
252
- element.role = role;
253
- element.textContent = message;
254
- element.dataset.autocompleteStatus = state;
255
- list.replaceChildren(element);
256
- open(true);
257
- }
258
- function open(allowStatus = false) {
259
- if (destroyed || !allowStatus && suggestions.length === 0) return;
260
- list.hidden = false;
261
- input.setAttribute("aria-expanded", "true");
262
- }
263
- function close() {
264
- activeIndex = -1;
265
- list.hidden = true;
266
- input.setAttribute("aria-expanded", "false");
267
- input.removeAttribute("aria-activedescendant");
268
- updateActiveOption();
269
- }
270
- function dismiss() {
271
- cancel();
272
- close();
273
- }
274
- function clear() {
506
+ };
507
+ const scheduleSearch = () => {
508
+ clearTimeout(debounceTimer);
509
+ const query = input.value.trim();
510
+ if (selected !== null) {
511
+ selected = null;
512
+ dispatch("autocomplete:clear", { query });
513
+ }
514
+ if (query.length < minLength) {
515
+ dismiss();
516
+ return;
517
+ }
518
+ if (debounce === 0) {
519
+ void search(query);
520
+ return;
521
+ }
522
+ debounceTimer = setTimeout(() => void search(query), debounce);
523
+ };
524
+ const clear = () => {
275
525
  const hadSelection = selected !== null;
276
526
  cancel();
277
527
  suggestions = [];
@@ -279,70 +529,36 @@ function createAutocompleteController(target, options) {
279
529
  selected = null;
280
530
  input.value = "";
281
531
  close();
532
+ announce("");
282
533
  if (hadSelection) dispatch("autocomplete:clear", { query: "" });
283
- }
284
- function moveActiveOption(direction) {
534
+ };
535
+ const moveActiveOption = (direction) => {
285
536
  if (list.hidden || suggestions.length === 0) return;
286
537
  activeIndex = activeIndex === -1 ? direction > 0 ? 0 : suggestions.length - 1 : (activeIndex + direction + suggestions.length) % suggestions.length;
287
538
  updateActiveOption();
288
- }
289
- function updateActiveOption() {
290
- const options2 = list.querySelectorAll("[data-autocomplete-option]");
291
- options2.forEach((option, index) => {
292
- const active2 = index === activeIndex;
293
- option.dataset.active = String(active2);
294
- option.setAttribute("aria-selected", String(active2));
295
- });
296
- const active = options2.item(activeIndex);
297
- if (!active) {
298
- input.removeAttribute("aria-activedescendant");
299
- return;
300
- }
301
- input.setAttribute("aria-activedescendant", active.id);
302
- active.scrollIntoView({ block: "nearest" });
303
- }
304
- function select(index) {
305
- const suggestion = suggestions[index];
306
- if (!suggestion) return;
307
- selected = suggestion.data;
308
- input.value = suggestion.value;
309
- dismiss();
310
- dispatch("autocomplete:select", {
311
- suggestion: suggestion.data,
312
- label: suggestion.label,
313
- value: suggestion.value
314
- });
315
- settings.onSelect?.(suggestion.data, input);
316
- }
317
- function dispatch(name, detail) {
318
- input.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
319
- }
320
- function cancel() {
321
- window.clearTimeout(debounceTimer);
322
- debounceTimer = void 0;
323
- requestSequence += 1;
324
- abortController?.abort();
325
- abortController = null;
326
- }
327
- function handleFocus() {
539
+ };
540
+ const handleInput = (event) => {
541
+ if (event.isComposing) return;
542
+ scheduleSearch();
543
+ };
544
+ const handleFocus = () => {
328
545
  if (suppressFocusSearch) return;
329
- if (settings.searchOnFocus && input.value.trim().length >= settings.minLength) {
330
- scheduleSearch();
331
- }
332
- }
333
- function handleKeydown(event) {
546
+ if (searchOnFocus && input.value.trim().length >= minLength) scheduleSearch();
547
+ };
548
+ const handleKeydown = (event) => {
549
+ const listOpen = !list.hidden && suggestions.length > 0;
334
550
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
335
- if (suggestions.length === 0 || list.hidden) return;
551
+ if (!listOpen) return;
336
552
  event.preventDefault();
337
553
  moveActiveOption(event.key === "ArrowDown" ? 1 : -1);
338
554
  return;
339
555
  }
340
- if (event.key === "Enter" && !list.hidden && activeIndex >= 0) {
556
+ if (event.key === "Enter" && listOpen && activeIndex >= 0) {
341
557
  event.preventDefault();
342
558
  select(activeIndex);
343
559
  return;
344
560
  }
345
- if ((event.key === "Home" || event.key === "End") && !list.hidden && suggestions.length > 0) {
561
+ if ((event.key === "Home" || event.key === "End") && listOpen) {
346
562
  event.preventDefault();
347
563
  activeIndex = event.key === "Home" ? 0 : suggestions.length - 1;
348
564
  updateActiveOption();
@@ -354,26 +570,11 @@ function createAutocompleteController(target, options) {
354
570
  return;
355
571
  }
356
572
  if (event.key === "Tab") dismiss();
357
- }
358
- function handleOptionPointerDown(event) {
359
- event.preventDefault();
360
- const element = event.currentTarget;
361
- select(Number(element.dataset.index));
362
- if (document.activeElement !== input) {
363
- suppressFocusSearch = true;
364
- input.focus({ preventScroll: true });
365
- suppressFocusSearch = false;
366
- }
367
- }
368
- function handleOptionPointerEnter(event) {
369
- const element = event.currentTarget;
370
- activeIndex = Number(element.dataset.index);
371
- updateActiveOption();
372
- }
373
- function handleDocumentPointerDown(event) {
374
- if (event.target !== input && !list.contains(event.target)) dismiss();
375
- }
376
- function destroy2() {
573
+ };
574
+ const handleDocumentPointerDown = (event) => {
575
+ if (event.target !== input && !popup.contains(event.target)) dismiss();
576
+ };
577
+ const destroy = () => {
377
578
  if (destroyed) return;
378
579
  destroyed = true;
379
580
  cancel();
@@ -382,9 +583,12 @@ function createAutocompleteController(target, options) {
382
583
  input.removeEventListener("focus", handleFocus);
383
584
  input.removeEventListener("keydown", handleKeydown);
384
585
  document.removeEventListener("pointerdown", handleDocumentPointerDown);
385
- list.remove();
586
+ popup.remove();
587
+ live.remove();
588
+ cache?.clear();
386
589
  originalAttributes.forEach((value, name) => restoreAttribute(input, name, value));
387
- }
590
+ if (label) restoreAttribute(label, "id", originalLabelId);
591
+ };
388
592
  input.addEventListener("input", handleInput);
389
593
  input.addEventListener("compositionend", scheduleSearch);
390
594
  input.addEventListener("focus", handleFocus);
@@ -392,53 +596,102 @@ function createAutocompleteController(target, options) {
392
596
  document.addEventListener("pointerdown", handleDocumentPointerDown);
393
597
  return {
394
598
  element: input,
599
+ popupElement: popup,
395
600
  listElement: list,
601
+ statusElement: status,
602
+ liveElement: live,
396
603
  options,
397
604
  search,
398
605
  close: dismiss,
399
606
  clear,
400
607
  cancel,
608
+ clearCache: () => cache?.clear(),
401
609
  getSelected: () => selected,
402
- destroy: destroy2
610
+ destroy
403
611
  };
404
- }
405
- function isAbortError(error) {
406
- return error instanceof DOMException && error.name === "AbortError";
407
- }
408
-
409
- // src/api/create-autocomplete.ts
410
- function createAutocomplete(target, options) {
411
- return createAutocompleteController(target, options);
412
- }
413
- var autocomplete = createAutocomplete;
612
+ };
613
+ var createAutocomplete = autocomplete;
414
614
 
415
615
  // src/browser/registry.ts
416
616
  var available = /* @__PURE__ */ Object.create(null);
417
- function create(target, options) {
418
- const input = resolveInput(target);
419
- if (!input.id) throw new TypeError("Autocomplete.create requires an input with an id.");
420
- available[input.id]?.destroy();
421
- const controller = createAutocomplete(input, options);
422
- available[input.id] = controller;
423
- return controller;
424
- }
425
- function destroy(id) {
426
- available[id]?.destroy();
617
+ var removeRegistration = (id) => {
618
+ const entry = available[id];
619
+ if (!entry) return;
427
620
  delete available[id];
428
- }
429
- var browser = {
430
- autocomplete: createAutocomplete,
431
- create,
432
- destroy,
621
+ entry.destroy();
622
+ };
623
+ var newAutocomplete = ({
624
+ id,
625
+ options
626
+ }) => {
627
+ if (!id) {
628
+ console.error("Autocomplete ID is required");
629
+ return null;
630
+ }
631
+ const input = document.getElementById(id);
632
+ if (!(input instanceof HTMLInputElement)) {
633
+ console.warn(`Autocomplete input with ID ${id} not found`);
634
+ return null;
635
+ }
636
+ removeRegistration(id);
637
+ const controller = autocomplete(input, options);
638
+ const destroy = controller.destroy;
639
+ const registered = {
640
+ ...controller,
641
+ destroy: () => {
642
+ if (available[id] === registered) delete available[id];
643
+ destroy();
644
+ }
645
+ };
646
+ available[id] = registered;
647
+ return registered;
648
+ };
649
+ var getAutocomplete = (id) => available[id] ?? null;
650
+ var getAutocompletes = () => ({
651
+ ...available
652
+ });
653
+ var destroyAutocomplete = (target) => {
654
+ if (typeof target === "string") return removeRegistration(target);
655
+ const entry = Object.entries(available).find(([, controller]) => controller.element === target);
656
+ if (entry) removeRegistration(entry[0]);
657
+ };
658
+ var destroyAutocompletes = () => Object.keys(available).forEach(removeRegistration);
659
+ var Autocomplete = {
660
+ autocomplete,
661
+ newAutocomplete,
662
+ getAutocomplete,
663
+ getAutocompletes,
664
+ destroyAutocomplete,
665
+ destroyAutocompletes,
433
666
  available
434
667
  };
668
+ var browser = Autocomplete;
435
669
  // Annotate the CommonJS export names for ESM import in node:
436
670
  0 && (module.exports = {
671
+ Autocomplete,
672
+ DEFAULT_CACHE_SIZE,
673
+ DEFAULT_CACHE_TTL,
674
+ DEFAULT_DEBOUNCE,
675
+ DEFAULT_MAX_RESULTS,
676
+ DEFAULT_MESSAGES,
677
+ DEFAULT_MIN_LENGTH,
678
+ DEFAULT_QUERY_PARAM,
679
+ HIGHLIGHT_ATTRIBUTE,
680
+ LIST_ATTRIBUTE,
681
+ LIVE_ATTRIBUTE,
682
+ OPTION_ATTRIBUTE,
683
+ POPUP_ATTRIBUTE,
684
+ STATUS_ATTRIBUTE,
437
685
  autocomplete,
438
686
  browser,
439
687
  createAutocomplete,
440
- createAutocompleteController,
688
+ createCache,
689
+ filterSuggestions,
690
+ fromEndpoint,
691
+ highlight,
441
692
  mapCommonResponse,
442
- parseJsonResponse
693
+ normalizeText,
694
+ parseJsonResponse,
695
+ renderHighlight
443
696
  });
444
697
  //# sourceMappingURL=index.cjs.map