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