@samline/autocomplete 0.0.0-stage → 1.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.
- package/CHANGELOG.md +23 -0
- package/LICENSE.md +21 -0
- package/README.md +138 -2
- package/dist/browser/global.d.ts +61 -0
- package/dist/browser/global.global.js +437 -0
- package/dist/browser/global.global.js.map +1 -0
- package/dist/index.cjs +444 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +71 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.js +412 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +51 -0
- package/package.json +112 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@samline/autocomplete` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
|
+
|
|
5
|
+
## [1.0.0] - 2026-11-13
|
|
6
|
+
|
|
7
|
+
First public release.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `createAutocomplete(input, options)` (alias `autocomplete`) turns an existing text input into an accessible remote combobox: debounce, abortable requests, stale-response protection, listbox rendering, keyboard and pointer selection, events and cleanup.
|
|
12
|
+
- Options: `endpoint`, `queryParam`, `requestInit`, `parseResponse`, `mapResponse`, `getId`, `getLabel`, `getValue`, `renderOption`, `minLength`, `debounce`, `maxResults`, `searchOnFocus`, `messages`, `onSelect` and `onError`.
|
|
13
|
+
- Events `autocomplete:loadstart`, `autocomplete:load`, `autocomplete:error`, `autocomplete:select` and `autocomplete:clear`.
|
|
14
|
+
- Keyboard: ArrowUp, ArrowDown, Home, End, Enter, Escape and Tab. Searches wait for IME composition to finish.
|
|
15
|
+
- Browser global `window.Autocomplete` with `create`, `destroy`, `available` and `autocomplete`, and an optional stylesheet themed with `--autocomplete-*` custom properties.
|
|
16
|
+
- Website with examples and documentation at https://samline.github.io/autocomplete/.
|
|
17
|
+
|
|
18
|
+
### Fixed (since the unpublished 0.1.0)
|
|
19
|
+
|
|
20
|
+
- Escape, Tab, clicking outside, selecting a suggestion and `close()` cancel the pending debounced search and abort the request in flight. Before, a late response reopened the list after it was dismissed.
|
|
21
|
+
- The standalone script defines `window.Autocomplete` with its documented members. Before, the global was wrapped as `{ default }`.
|
|
22
|
+
|
|
23
|
+
[1.0.0]: https://github.com/samline/autocomplete/releases/tag/v1.0.0
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 samline
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,139 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @samline/autocomplete
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An accessible, framework-agnostic remote autocomplete (combobox) for vanilla JavaScript, TypeScript and plain HTML. Point an input at your endpoint; debounce, cancellation, keyboard navigation, events and cleanup are handled for you.
|
|
4
|
+
|
|
5
|
+
[Demo](https://samline.github.io/autocomplete/) · [Examples](https://samline.github.io/autocomplete/examples/) · [Documentation](https://samline.github.io/autocomplete/docs/) · [Changelog](CHANGELOG.md)
|
|
6
|
+
|
|
7
|
+
## Table of contents
|
|
8
|
+
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Quick start](#quick-start)
|
|
11
|
+
- [HTML / CDN](#html--cdn)
|
|
12
|
+
- [Capabilities](#capabilities)
|
|
13
|
+
- [API at a glance](#api-at-a-glance)
|
|
14
|
+
- [Documentation](#documentation)
|
|
15
|
+
- [Changelog](#changelog)
|
|
16
|
+
- [AI agent skill](#ai-agent-skill)
|
|
17
|
+
- [License](#license)
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install @samline/autocomplete
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pnpm add @samline/autocomplete
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
bun add @samline/autocomplete
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Works in modern browsers with `fetch` and `AbortController`, and imports in Node 20 or newer. No runtime dependencies; the stylesheet is optional.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```html
|
|
38
|
+
<div class="field" style="position: relative">
|
|
39
|
+
<label for="city">City</label>
|
|
40
|
+
<input id="city" type="text" />
|
|
41
|
+
</div>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
import { createAutocomplete } from '@samline/autocomplete'
|
|
46
|
+
import '@samline/autocomplete/styles.css'
|
|
47
|
+
|
|
48
|
+
const controller = createAutocomplete('#city', {
|
|
49
|
+
endpoint: '/api/cities', // GET /api/cities?q=<query>
|
|
50
|
+
getId: city => city.id,
|
|
51
|
+
getLabel: city => city.name,
|
|
52
|
+
onSelect: city => console.log('Selected', city),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
controller.destroy() // removes the list and restores the input
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## HTML / CDN
|
|
59
|
+
|
|
60
|
+
For Shopify, WordPress or plain HTML pages without a build step, load the stylesheet and the browser bundle. Pin the version in production.
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@samline/autocomplete@1.0.0/dist/styles.css" />
|
|
64
|
+
<script src="https://cdn.jsdelivr.net/npm/@samline/autocomplete@1.0.0/dist/browser/global.global.js"></script>
|
|
65
|
+
<script>
|
|
66
|
+
window.Autocomplete.create('#city', { endpoint: '/api/cities' })
|
|
67
|
+
</script>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`window.Autocomplete` has `create(target, options)` (requires an input id and registers the controller), `destroy(id)`, `available` and `autocomplete(target, options)`.
|
|
71
|
+
|
|
72
|
+
## Capabilities
|
|
73
|
+
|
|
74
|
+
### Any endpoint, any response
|
|
75
|
+
|
|
76
|
+
`endpoint` is a URL or a function of the query. `requestInit` adds headers or credentials, `parseResponse` replaces JSON parsing, and `mapResponse`, `getId`, `getLabel` and `getValue` adapt whatever your API returns.
|
|
77
|
+
|
|
78
|
+
### An accessible combobox
|
|
79
|
+
|
|
80
|
+
ARIA `combobox` and `listbox` roles, `aria-expanded` and `aria-activedescendant`, status and error messages, and ArrowUp, ArrowDown, Home, End, Enter, Escape and Tab. Searches wait for IME composition to finish.
|
|
81
|
+
|
|
82
|
+
### No stale results
|
|
83
|
+
|
|
84
|
+
Searches are debounced, superseded requests are aborted and late responses ignored. Closing the list (Escape, Tab, clicking outside, selecting or `close()`) cancels the pending search.
|
|
85
|
+
|
|
86
|
+
### Events and styling
|
|
87
|
+
|
|
88
|
+
`autocomplete:select`, `autocomplete:clear`, `autocomplete:load`, `autocomplete:loadstart` and `autocomplete:error` bubble from the input. The optional stylesheet is themed with `--autocomplete-*` custom properties, and `renderOption` adds your own content to each option.
|
|
89
|
+
|
|
90
|
+
## API at a glance
|
|
91
|
+
|
|
92
|
+
| API | Purpose |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `createAutocomplete(input, options)` | Attach the combobox and return a controller (`autocomplete` is an alias). |
|
|
95
|
+
| `controller.search(query?)` / `close()` / `clear()` / `cancel()` | Run a search now, close the list, clear everything or cancel pending work. |
|
|
96
|
+
| `controller.getSelected()` / `destroy()` | Read the selected suggestion or remove the combobox. |
|
|
97
|
+
| `mapCommonResponse`, `parseJsonResponse` | The default response mapper and parser. |
|
|
98
|
+
|
|
99
|
+
### Entrypoints
|
|
100
|
+
|
|
101
|
+
| Entrypoint | When to use |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `@samline/autocomplete` | ESM or CommonJS: `createAutocomplete`, helpers, `browser` registry and types. |
|
|
104
|
+
| `@samline/autocomplete/browser` | Classic script that defines `window.Autocomplete`. |
|
|
105
|
+
| `@samline/autocomplete/styles.css` | Optional base stylesheet. |
|
|
106
|
+
|
|
107
|
+
## Documentation
|
|
108
|
+
|
|
109
|
+
Read the [documentation](https://samline.github.io/autocomplete/docs/) for the full guides and API contracts, and explore [working examples](https://samline.github.io/autocomplete/examples/) with complete and separate source files.
|
|
110
|
+
|
|
111
|
+
| Guide | What it covers |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| [Installation](https://samline.github.io/autocomplete/docs/getting-started/) | Package / ESM, HTML / CDN and working starter files. |
|
|
114
|
+
| [Options](https://samline.github.io/autocomplete/docs/reference/configuration/) | Every option and its default. |
|
|
115
|
+
| [API reference](https://samline.github.io/autocomplete/docs/reference/api/) | The controller, helpers, browser global and events. |
|
|
116
|
+
| [Accessibility](https://samline.github.io/autocomplete/docs/guides/accessibility/) | What the combobox provides and what your page provides. |
|
|
117
|
+
| [Styling](https://samline.github.io/autocomplete/docs/guides/styling/) | The DOM contract and the custom properties. |
|
|
118
|
+
|
|
119
|
+
## Changelog
|
|
120
|
+
|
|
121
|
+
Read [CHANGELOG.md](CHANGELOG.md) or the [published changelog](https://samline.github.io/autocomplete/docs/changelog/) for release history and explicitly marked breaking and behavior changes. Review the notes before upgrading.
|
|
122
|
+
|
|
123
|
+
## AI agent skill
|
|
124
|
+
|
|
125
|
+
The [Autocomplete skill](https://samline.github.io/autocomplete/skills/autocomplete/SKILL.md) teaches verified package integration. Its maintained [repository source](skills/autocomplete/SKILL.md) and website download are the same file. Verify the installed package version before applying its guidance.
|
|
126
|
+
|
|
127
|
+
Download [SKILL.md](https://samline.github.io/autocomplete/skills/autocomplete/SKILL.md) into an `autocomplete/` folder in your agent's supported skill directory. For agents that discover project skills from `.agents/skills/`:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
mkdir -p .agents/skills/autocomplete
|
|
131
|
+
curl --fail --location https://samline.github.io/autocomplete/skills/autocomplete/SKILL.md \
|
|
132
|
+
--output .agents/skills/autocomplete/SKILL.md
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
When working from this repository, copy `skills/autocomplete/SKILL.md` instead. Keep the filename `SKILL.md`; discovery paths and invocation syntax depend on the agent.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT — [LICENSE.md](LICENSE.md).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type AutocompleteTarget = string | HTMLInputElement;
|
|
2
|
+
type AutocompleteEndpoint = string | URL | ((query: string, input: HTMLInputElement) => string | URL);
|
|
3
|
+
type AutocompleteRequestInit = RequestInit | ((query: string, input: HTMLInputElement) => RequestInit);
|
|
4
|
+
type AutocompleteParser = (response: Response, query: string) => Promise<unknown>;
|
|
5
|
+
type AutocompleteResponseMapper<T> = (payload: unknown, response: Response, query: string) => readonly T[] | Promise<readonly T[]>;
|
|
6
|
+
interface AutocompleteMessages {
|
|
7
|
+
loading: string;
|
|
8
|
+
empty: string;
|
|
9
|
+
error: string;
|
|
10
|
+
}
|
|
11
|
+
interface AutocompleteOptions<T = unknown> {
|
|
12
|
+
endpoint: AutocompleteEndpoint;
|
|
13
|
+
queryParam?: string | null;
|
|
14
|
+
requestInit?: AutocompleteRequestInit;
|
|
15
|
+
parseResponse?: AutocompleteParser;
|
|
16
|
+
mapResponse?: AutocompleteResponseMapper<T>;
|
|
17
|
+
getId?: (suggestion: T, index: number) => string | number;
|
|
18
|
+
getLabel?: (suggestion: T) => string;
|
|
19
|
+
getValue?: (suggestion: T) => string;
|
|
20
|
+
renderOption?: (element: HTMLButtonElement, suggestion: T) => void;
|
|
21
|
+
minLength?: number;
|
|
22
|
+
debounce?: number;
|
|
23
|
+
maxResults?: number;
|
|
24
|
+
searchOnFocus?: boolean;
|
|
25
|
+
messages?: Partial<AutocompleteMessages>;
|
|
26
|
+
onSelect?: (suggestion: T, input: HTMLInputElement) => void;
|
|
27
|
+
onError?: (error: unknown, input: HTMLInputElement) => void;
|
|
28
|
+
}
|
|
29
|
+
interface AutocompleteController<T = unknown> {
|
|
30
|
+
readonly element: HTMLInputElement;
|
|
31
|
+
readonly listElement: HTMLDivElement;
|
|
32
|
+
readonly options: Readonly<AutocompleteOptions<T>>;
|
|
33
|
+
search(query?: string): Promise<void>;
|
|
34
|
+
close(): void;
|
|
35
|
+
clear(): void;
|
|
36
|
+
cancel(): void;
|
|
37
|
+
getSelected(): T | null;
|
|
38
|
+
destroy(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare function createAutocomplete<T = unknown>(target: AutocompleteTarget, options: AutocompleteOptions<T>): AutocompleteController<T>;
|
|
42
|
+
|
|
43
|
+
type AutocompleteAvailable = Record<string, AutocompleteController<unknown>>;
|
|
44
|
+
interface AutocompleteApi {
|
|
45
|
+
autocomplete: typeof createAutocomplete;
|
|
46
|
+
create: <T>(target: AutocompleteTarget, options: AutocompleteOptions<T>) => AutocompleteController<T>;
|
|
47
|
+
destroy: (id: string) => void;
|
|
48
|
+
available: AutocompleteAvailable;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
declare global {
|
|
52
|
+
interface Window {
|
|
53
|
+
Autocomplete?: AutocompleteApi;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
declare const autocomplete: typeof createAutocomplete;
|
|
57
|
+
declare const create: <T>(target: AutocompleteTarget, options: AutocompleteOptions<T>) => AutocompleteController<T>;
|
|
58
|
+
declare const destroy: (id: string) => void;
|
|
59
|
+
declare const available: AutocompleteAvailable;
|
|
60
|
+
|
|
61
|
+
export { autocomplete, available, create, destroy };
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var Autocomplete = (() => {
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/browser/global.ts
|
|
22
|
+
var global_exports = {};
|
|
23
|
+
__export(global_exports, {
|
|
24
|
+
autocomplete: () => autocomplete,
|
|
25
|
+
available: () => available2,
|
|
26
|
+
create: () => create2,
|
|
27
|
+
destroy: () => destroy2
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// src/core/defaults.ts
|
|
31
|
+
var DEFAULT_MESSAGES = {
|
|
32
|
+
loading: "Searching...",
|
|
33
|
+
empty: "No results",
|
|
34
|
+
error: "Search failed"
|
|
35
|
+
};
|
|
36
|
+
var parseJsonResponse = async (response) => response.json();
|
|
37
|
+
var mapCommonResponse = (payload) => {
|
|
38
|
+
if (Array.isArray(payload)) return payload;
|
|
39
|
+
if (!payload || typeof payload !== "object") throw invalidPayloadError();
|
|
40
|
+
const record = payload;
|
|
41
|
+
if (Array.isArray(record.suggestions)) return record.suggestions;
|
|
42
|
+
if (Array.isArray(record.items)) return record.items;
|
|
43
|
+
if (Array.isArray(record.data)) return record.data;
|
|
44
|
+
throw invalidPayloadError();
|
|
45
|
+
};
|
|
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) {
|
|
53
|
+
if (typeof suggestion === "string") return suggestion;
|
|
54
|
+
if (!suggestion || typeof suggestion !== "object") return String(suggestion ?? "");
|
|
55
|
+
const record = suggestion;
|
|
56
|
+
return String(record.value ?? record.label ?? record.title ?? "");
|
|
57
|
+
}
|
|
58
|
+
function getDefaultId(suggestion, index) {
|
|
59
|
+
if (!suggestion || typeof suggestion !== "object") return index;
|
|
60
|
+
return suggestion.id ?? index;
|
|
61
|
+
}
|
|
62
|
+
function validateOptions(options) {
|
|
63
|
+
if (!options.endpoint) throw new TypeError("Autocomplete requires an endpoint.");
|
|
64
|
+
assertNonNegativeInteger(options.minLength, "minLength");
|
|
65
|
+
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.");
|
|
68
|
+
}
|
|
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.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function invalidPayloadError() {
|
|
76
|
+
return new TypeError(
|
|
77
|
+
"Autocomplete response does not contain an array. Configure mapResponse(payload, response, query)."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/core/dom.ts
|
|
82
|
+
function resolveInput(target) {
|
|
83
|
+
const element = typeof target === "string" ? document.querySelector(target) : target;
|
|
84
|
+
if (!(element instanceof HTMLInputElement)) {
|
|
85
|
+
throw new TypeError("Autocomplete requires an HTMLInputElement or a valid selector.");
|
|
86
|
+
}
|
|
87
|
+
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;
|
|
96
|
+
}
|
|
97
|
+
element.setAttribute(name, value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/core/controller.ts
|
|
101
|
+
var instanceCount = 0;
|
|
102
|
+
function createAutocompleteController(target, options) {
|
|
103
|
+
validateOptions(options);
|
|
104
|
+
const input = resolveInput(target);
|
|
105
|
+
const instanceId = `autocomplete-${++instanceCount}`;
|
|
106
|
+
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
|
+
};
|
|
124
|
+
const list = document.createElement("div");
|
|
125
|
+
const originalAttributes = /* @__PURE__ */ new Map();
|
|
126
|
+
let suggestions = [];
|
|
127
|
+
let activeIndex = -1;
|
|
128
|
+
let selected = null;
|
|
129
|
+
let debounceTimer;
|
|
130
|
+
let abortController = null;
|
|
131
|
+
let requestSequence = 0;
|
|
132
|
+
let destroyed = false;
|
|
133
|
+
let suppressFocusSearch = false;
|
|
134
|
+
list.id = `${instanceId}-listbox`;
|
|
135
|
+
list.hidden = true;
|
|
136
|
+
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));
|
|
147
|
+
}
|
|
148
|
+
input.role = "combobox";
|
|
149
|
+
input.autocomplete = "off";
|
|
150
|
+
input.setAttribute("aria-autocomplete", "list");
|
|
151
|
+
input.setAttribute("aria-controls", list.id);
|
|
152
|
+
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();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
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) {
|
|
184
|
+
close();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
cancel();
|
|
188
|
+
const sequence = ++requestSequence;
|
|
189
|
+
const controller = new AbortController();
|
|
190
|
+
abortController = controller;
|
|
191
|
+
renderStatus(messages.loading, "status", "loading");
|
|
192
|
+
dispatch("autocomplete:loadstart", { query });
|
|
193
|
+
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.");
|
|
200
|
+
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
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (isAbortError(error) || sequence !== requestSequence) return;
|
|
211
|
+
suggestions = [];
|
|
212
|
+
activeIndex = -1;
|
|
213
|
+
renderStatus(messages.error, "alert", "error");
|
|
214
|
+
dispatch("autocomplete:error", { query, error });
|
|
215
|
+
settings.onError?.(error, input);
|
|
216
|
+
} finally {
|
|
217
|
+
if (sequence === requestSequence) abortController = null;
|
|
218
|
+
}
|
|
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() {
|
|
273
|
+
const hadSelection = selected !== null;
|
|
274
|
+
cancel();
|
|
275
|
+
suggestions = [];
|
|
276
|
+
activeIndex = -1;
|
|
277
|
+
selected = null;
|
|
278
|
+
input.value = "";
|
|
279
|
+
close();
|
|
280
|
+
if (hadSelection) dispatch("autocomplete:clear", { query: "" });
|
|
281
|
+
}
|
|
282
|
+
function moveActiveOption(direction) {
|
|
283
|
+
if (list.hidden || suggestions.length === 0) return;
|
|
284
|
+
activeIndex = activeIndex === -1 ? direction > 0 ? 0 : suggestions.length - 1 : (activeIndex + direction + suggestions.length) % suggestions.length;
|
|
285
|
+
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() {
|
|
326
|
+
if (suppressFocusSearch) return;
|
|
327
|
+
if (settings.searchOnFocus && input.value.trim().length >= settings.minLength) {
|
|
328
|
+
scheduleSearch();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function handleKeydown(event) {
|
|
332
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
333
|
+
if (suggestions.length === 0 || list.hidden) return;
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
moveActiveOption(event.key === "ArrowDown" ? 1 : -1);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (event.key === "Enter" && !list.hidden && activeIndex >= 0) {
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
select(activeIndex);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if ((event.key === "Home" || event.key === "End") && !list.hidden && suggestions.length > 0) {
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
activeIndex = event.key === "Home" ? 0 : suggestions.length - 1;
|
|
346
|
+
updateActiveOption();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (event.key === "Escape") {
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
dismiss();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
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() {
|
|
375
|
+
if (destroyed) return;
|
|
376
|
+
destroyed = true;
|
|
377
|
+
cancel();
|
|
378
|
+
input.removeEventListener("input", handleInput);
|
|
379
|
+
input.removeEventListener("compositionend", scheduleSearch);
|
|
380
|
+
input.removeEventListener("focus", handleFocus);
|
|
381
|
+
input.removeEventListener("keydown", handleKeydown);
|
|
382
|
+
document.removeEventListener("pointerdown", handleDocumentPointerDown);
|
|
383
|
+
list.remove();
|
|
384
|
+
originalAttributes.forEach((value, name) => restoreAttribute(input, name, value));
|
|
385
|
+
}
|
|
386
|
+
input.addEventListener("input", handleInput);
|
|
387
|
+
input.addEventListener("compositionend", scheduleSearch);
|
|
388
|
+
input.addEventListener("focus", handleFocus);
|
|
389
|
+
input.addEventListener("keydown", handleKeydown);
|
|
390
|
+
document.addEventListener("pointerdown", handleDocumentPointerDown);
|
|
391
|
+
return {
|
|
392
|
+
element: input,
|
|
393
|
+
listElement: list,
|
|
394
|
+
options,
|
|
395
|
+
search,
|
|
396
|
+
close: dismiss,
|
|
397
|
+
clear,
|
|
398
|
+
cancel,
|
|
399
|
+
getSelected: () => selected,
|
|
400
|
+
destroy: destroy3
|
|
401
|
+
};
|
|
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
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/browser/registry.ts
|
|
413
|
+
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();
|
|
424
|
+
delete available[id];
|
|
425
|
+
}
|
|
426
|
+
var browser = {
|
|
427
|
+
autocomplete: createAutocomplete,
|
|
428
|
+
create,
|
|
429
|
+
destroy,
|
|
430
|
+
available
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// src/browser/global.ts
|
|
434
|
+
var { autocomplete, create: create2, destroy: destroy2, available: available2 } = browser;
|
|
435
|
+
return __toCommonJS(global_exports);
|
|
436
|
+
})();
|
|
437
|
+
//# sourceMappingURL=global.global.js.map
|