AutocompleteMOL
A search field that suggests as you type - from records in the page, from a URL, or from a network client you write. Every search is a defuss-dataview request (query, filters, sorters, page, pageSize), so a pre-configured sort and filters travel with it and local records are answered by the same engine a server can run. Typing is debounced; the next keystroke aborts the request still in flight, so a stale answer never lands; more results load as the list scrolls.
On this page (10)
§A remote source - debounced, cancelled, paged
load(request, { signal }) is a fake server here: 96,000 places, 350 ms per request, answering the dataview request with dataview itself. Type 'new ber' fast - one request after you pause (data-debounce='250'); keep typing while one is on its way and it is ABORTED (the log counts both). The sort is pre-configured (population, largest first) and scrolling the list asks for the next page.
§Local records
rows: the records are in the page - a dataview source filters (case-insensitive), sorts and pages them; no network. data-match='startsWith' and data-sort='name:asc' are the pre-configured query; data-debounce='0' searches on every key.
§Your own network client
The default client GETs data-url with ?q=&page=&pageSize=&sort= - client.fetch swaps the transport (auth, retries, a mock: here a fake fetch that answers with a Response), client.headers adds headers, client.parse maps any JSON to { rows, total, hasMore }. The signal still reaches your fetch.
§A live API
url(request) builds the request for a public API (Open-Meteo's geocoding - no key) and client.parse reads its { results }; it returns one page, so hasMore is false. Real network: watch the requests in the devtools - typing on cancels the one in flight.
§In a form
The chosen value goes to the hidden .autocomplete-value (data-value-field); autocomplete-select carries the whole record. The form below reads FormData on submit.
§Empty and error
A query nothing matches shows data-empty-text; a source that throws shows its message and a Retry button (the 'error' state, config.message).
§States
Named states via the shared State API, driven per instance through the bound api; the config is { query, value, label } (merged on each setState), observable as el.store:
default- closed, nothing in flightopen- the suggestions;setState('open', { query })types and searchesloading- the first page is on its way (aria-busy)empty- nothing matched:data-empty-texterror- the source failed: its message (config.message) and Retry
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/autocomplete-{state}.png.
Machine contract - verified against autocomplete.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
open | boolean | true, false | false | The list shows suggestions (setState('open')). |
loading | boolean | true, false | false | The first page is on its way (setState('loading')). |
empty | boolean | true, false | false | Nothing matched - shows data-empty-text (setState('empty')). |
error | boolean | true, false | false | The source failed - its message and Retry (setState('error')). |
§API
Generated from autocomplete.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type AutocompleteState = 'default' | 'open' | 'loading' | 'empty' | 'error' - setState(name, config) takes the config of the state it names.
| State | Description | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
default | Closed, nothing in flight.
| |||||||||||||||
open | The list shows suggestions - opening with nothing listed searches for what is typed.
| |||||||||||||||
loading | The first page is on its way: placeholder rows, aria-busy on the input.
| |||||||||||||||
empty | The query matched nothing - the data-empty-text shows.
| |||||||||||||||
error | The source failed: its message and a Retry button.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends AutocompleteState>(name: S, config?: AutocompleteStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: AutocompleteState; config: AutocompleteStateConfigs[AutocompleteState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: AutocompleteState; config: AutocompleteStateConfigs[AutocompleteState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: AutocompleteState; config: AutocompleteStateConfigs[AutocompleteState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.autocompleteApi.setState<S extends AutocompleteState>(el: HTMLElement, name: S, config?: AutocompleteStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.autocompleteApi.getState(el: HTMLElement): { name: AutocompleteState; config: AutocompleteStateConfigs[AutocompleteState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.autocompleteApi.render(state: { name: AutocompleteState; config: AutocompleteStateConfigs[AutocompleteState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.autocompleteApi.store(el: HTMLElement): Store<{ name: AutocompleteState; config: AutocompleteStateConfigs[AutocompleteState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.autocompleteApi.commit<S extends AutocompleteState>(el: HTMLElement, name: S, config?: AutocompleteStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.autocompleteStates: AutocompleteState[] | The declared states, 'default' first: default, open, loading, empty, error. |
df$.shadcn.autocomplete
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
configure(target: string | HTMLElement, config: AutocompleteConfig = {}): void | Bind data and behavior. Data (one of): rows (local records - a dataview source), url (string or (request) => url; the default client GETs it with ?q=&page=&pageSize=&sort=), load(request, { signal }) - your own client, returning records or { rows, hasMore, total }. client: { fetch, headers, parse(json, request) } customizes the default client. Query: searchField, match ('contains' | 'startsWith'), sorters, filters, pageSize, debounce (ms), minChars. Display: label / value (field names or functions), render(option, record, { query, index }).
| |||||||||
search(target: string | HTMLElement, query: string): Promise<void> | Search for a query now (no debounce): the input shows it and the list loads.
Returns | |||||||||
close(target: string | HTMLElement): void | Close the popup and cancel what is in flight.
| |||||||||
records(target: string | HTMLElement): DataviewRow[] | The records the list holds now.
Returns |
Events
| Event | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
autocomplete-request | Fires before every request (each page) - detail.request is the dataview request the loader receives.
| ||||||||||||
autocomplete-select | Fires when a suggestion is taken - its record, value and label.
|
Types
| Type | Description | |||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
AutocompleteAnswer | A loader's answer: the records, or the records with paging facts (items / data / results are read as rows too). = | |||||||||||||||||||||||||||||||||||||||||||||||||||
AutocompleteConfig | What configure() takes - every key optional; data attributes set the defaults.
| |||||||||||||||||||||||||||||||||||||||||||||||||||
AutocompleteRequest | One request to the loader - a dataview request, one page of one query.
| |||||||||||||||||||||||||||||||||||||||||||||||||||
AutocompleteRequestDetail | What autocomplete-request carries.
| |||||||||||||||||||||||||||||||||||||||||||||||||||
AutocompleteSelectDetail | What autocomplete-select carries.
|
§CSS view file
/* -- Autocomplete ------------------------------------------------ *//* An input and a manual popover under it (CSS anchor positioning): *//* the listbox, a status line, and the empty / error notes - which *//* one shows is the root's data-state. */@layer components { .autocomplete { position: relative; display: grid; gap: 0.375rem; width: 100%; } .autocomplete-input { width: 100%; } .autocomplete-popover { position: fixed; inset: auto; top: anchor(bottom); left: anchor(left); width: anchor-size(width); min-width: 12rem; max-height: min(22rem, 60dvh); margin: 0.25rem 0 0; padding: 0.25rem; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--popover); color: var(--popover-foreground); box-shadow: var(--shadow-md); overflow: hidden; position-try-fallbacks: flip-block; /* open: fade + slide in; closed: out of the layout */ opacity: 0; translate: 0 -4px; transition: opacity 120ms ease, translate 120ms ease, display 120ms allow-discrete, overlay 120ms allow-discrete; &:popover-open { display: flex; flex-direction: column; opacity: 1; translate: 0 0; @starting-style { opacity: 0; translate: 0 -4px; } } } .autocomplete-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; } .autocomplete-option { display: flex; align-items: center; gap: 0.5rem; min-height: 2rem; padding: 0.375rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.875rem; cursor: default; user-select: none; &[data-active] { background: var(--accent); color: var(--accent-foreground); } } .autocomplete-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* secondary text in an option (a country, a count) */ .autocomplete-meta { margin-inline-start: auto; color: var(--muted-foreground); font-size: 0.75rem; font-variant-numeric: tabular-nums; white-space: nowrap; } .autocomplete-match { background: transparent; color: inherit; font-weight: 600; } .autocomplete-status { padding: 0.375rem 0.5rem 0.125rem; color: var(--muted-foreground); font-size: 0.75rem; font-variant-numeric: tabular-nums; &:empty { display: none; } } .autocomplete-empty, .autocomplete-error { display: none; padding: 0.75rem 0.5rem; color: var(--muted-foreground); font-size: 0.875rem; text-align: center; } .autocomplete[data-state="empty"] .autocomplete-empty { display: block; } .autocomplete[data-state="error"] .autocomplete-error { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.5rem; color: var(--destructive); } .autocomplete[data-state="empty"] .autocomplete-list, .autocomplete[data-state="error"] .autocomplete-list, .autocomplete[data-state="loading"] .autocomplete-list { display: none; } /* first page on its way: placeholder rows, drawn rather than built */ .autocomplete[data-state="loading"] .autocomplete-popover::before { content: ""; display: block; height: 6rem; margin: 0.25rem; border-radius: var(--radius-sm); background: repeating-linear-gradient(to bottom, var(--muted) 0 1.5rem, transparent 1.5rem 2rem); animation: autocomplete-pulse 1.4s ease-in-out infinite; } .autocomplete-retry { padding: 0.125rem 0.625rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--background); color: var(--foreground); font: inherit; font-size: 0.8125rem; cursor: pointer; &:hover { background: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } } @keyframes autocomplete-pulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 0.35; } }}@media (prefers-reduced-motion: reduce) { @layer components { .autocomplete-popover, .autocomplete[data-state="loading"] .autocomplete-popover::before { transition: none; animation: none; } }}@media (prefers-contrast: more) { @layer components { .autocomplete-popover { border-color: var(--foreground); } .autocomplete-option[data-active] { outline: 2px solid var(--foreground); outline-offset: -2px; } }}@media (forced-colors: active) { @layer components { .autocomplete-popover { border-color: CanvasText; } .autocomplete-option[data-active] { background: Highlight; color: HighlightText; } .autocomplete-match { text-decoration: underline; } }}§JS view file
// -- Autocomplete ---------------------------------------------// A text input that suggests as you type, from data of any size and any// place: local records, a URL, or your own network client. Every request is// a defuss-dataview request - { query, filters, sorters, page, pageSize } -// so local records are filtered, sorted and paged by the same engine a server// can run, results arrive page by page as the list scrolls (infinite), typing// is debounced, and a newer query aborts the request still in flight// (AbortController) - a stale answer never lands.//// APG combobox (list autocomplete, listbox popup): focus stays in the input,// aria-activedescendant names the active option.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent, dataSource, safeShowPopover, textLocale } from '../../../shared/state-api.js';import type { DataviewFilter, DataviewRow, DataviewSorter } from '../../../shared/dataview.js';const df$ = defussGlobals();const dfDollar = defussQuery();// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type// this file's API docs state - arguments, return values, event details - holds// against its code: a wrong one is a new type error and fails the build./** One request to the loader - a dataview request, one page of one query. */interface AutocompleteRequest { /** the text typed */ query: string; /** the configured filters, plus the query as a filter on the search field */ filters: DataviewFilter[]; /** the configured sort order */ sorters: DataviewSorter[]; /** the page asked for, 0-based (scrolling to the end of the list asks for the next) */ page: number; /** records per page */ pageSize: number;}/** A loader's answer: the records, or the records with paging facts (items / data / results are read as rows too). */type AutocompleteAnswer = DataviewRow[] | { rows: DataviewRow[]; hasMore?: boolean; total?: number };/** What configure() takes - every key optional; data attributes set the defaults. */interface AutocompleteConfig { /** local records: a dataview source, filtered and sorted in the browser */ rows?: DataviewRow[]; /** a JSON endpoint the default client GETs with ?q=&page=&pageSize=&sort=, or a function building the URL per request */ url?: string | ((request: AutocompleteRequest) => string); /** your own client: answers a request (abort with the signal when the next keystroke supersedes it) */ load?: (request: AutocompleteRequest, options: { signal: AbortSignal }) => AutocompleteAnswer | Promise<AutocompleteAnswer>; /** the default client's fetch, extra headers and a parse step from the JSON to an answer */ client?: { fetch?: typeof fetch; headers?: Record<string, string>; parse?: (json: unknown, request: AutocompleteRequest) => AutocompleteAnswer }; /** the field the query matches (default: the label field) */ searchField?: string; /** how the query matches: anywhere in the field, or at its start */ match?: 'contains' | 'startsWith'; /** the sort order of the results */ sorters?: DataviewSorter[]; /** filters every request carries */ filters?: DataviewFilter[]; /** records per page (default 20) */ pageSize?: number; /** ms to wait after a keystroke before asking (default 200) */ debounce?: number; /** characters before the first request (default 1) */ minChars?: number; /** the field shown as the option text (default 'label') */ labelField?: string; /** the field written to the hidden value input (default 'id') */ valueField?: string; /** compute the option text instead of reading labelField */ label?: (record: DataviewRow) => string; /** compute the value instead of reading valueField */ value?: (record: DataviewRow) => unknown; /** fill an option element yourself (after the default label markup) */ render?: (option: HTMLElement, record: DataviewRow, context: { query: string; index: number }) => void;}/** What autocomplete-request carries. */interface AutocompleteRequestDetail { /** the request the loader receives next */ request: AutocompleteRequest;}/** What autocomplete-select carries. */interface AutocompleteSelectDetail { /** the record taken */ record: DataviewRow; /** its value (valueField or value()) - also in the hidden value input */ value: unknown; /** its label - now the input's text */ label: string;}const autocompleteStates = ['default', 'open', 'loading', 'empty', 'error'];/** setState() configs per state - merged into the stored one (a state change keeps the query and the choice). */export interface AutocompleteStateConfigs { /** Closed, nothing in flight. */ default: { /** the query: a string shows it in the input and searches for it; getState() reports the last one */ query?: string; /** reported by getState(): the chosen suggestion's value, null before a choice */ value?: unknown; /** reported by getState(): the chosen suggestion's label */ label?: string; }; /** The list shows suggestions - opening with nothing listed searches for what is typed. */ open: { /** the query: a string shows it in the input and searches for it; getState() reports the last one */ query?: string; /** reported by getState(): the chosen suggestion's value, null before a choice */ value?: unknown; /** reported by getState(): the chosen suggestion's label */ label?: string; }; /** The first page is on its way: placeholder rows, aria-busy on the input. */ loading: { /** the query: a string shows it in the input and searches for it; getState() reports the last one */ query?: string; /** reported by getState(): the chosen suggestion's value, null before a choice */ value?: unknown; /** reported by getState(): the chosen suggestion's label */ label?: string; }; /** The query matched nothing - the data-empty-text shows. */ empty: { /** the query: a string shows it in the input and searches for it; getState() reports the last one */ query?: string; /** reported by getState(): the chosen suggestion's value, null before a choice */ value?: unknown; /** reported by getState(): the chosen suggestion's label */ label?: string; }; /** The source failed: its message and a Retry button. */ error: { /** the message shown (default "Something went wrong.") */ message?: string; /** the query: a string shows it in the input and searches for it; getState() reports the last one */ query?: string; /** reported by getState(): the chosen suggestion's value, null before a choice */ value?: unknown; /** reported by getState(): the chosen suggestion's label */ label?: string; };}/** the states that show the popup */const SHOWN = new Set(['open', 'loading', 'empty', 'error']);let uid = 0;const num = (v, fallback) => (Number.isFinite(Number(v)) && v !== '' && v != null ? Number(v) : fallback);/** the parts of an instance (authored: input; the rest is found or made) */const inputOf = (root) => dfDollar(root).find<HTMLInputElement>('.autocomplete-input').get(0);const popoverOf = (root) => dfDollar(root).find('.autocomplete-popover').get(0);const listOf = (root) => dfDollar(root).find('.autocomplete-list').get(0);/** * The markup of a state, for render() AND the live element: data-state on * the root, aria-expanded / aria-busy on the input. The popup itself lives in * the top layer (a popover), not in an attribute. */function applyMarkup(root, state) { dfDollar(root).attr('data-state', state.name); const input = inputOf(root); if (input) { dfDollar(input) .attr('aria-expanded', SHOWN.has(state.name) ? 'true' : 'false') .attr('aria-busy', state.name === 'loading' ? 'true' : null); }}// -- configuration ---------------------------------------------------------------/** * The instance's config: attributes first (data-debounce, data-min-chars, * data-page-size, data-url, data-label-field, data-value-field, * data-search-field, data-sort="field:dir", data-match="contains|startsWith"), * then whatever configure() passed. */function configOf(root) { const d = root.dataset; const [sortField, sortDir] = (d.sort || '').split(':'); const base = { debounce: num(d.debounce, 200), minChars: num(d.minChars, 1), pageSize: num(d.pageSize, 20), url: d.url || null, labelField: d.labelField || 'label', valueField: d.valueField || 'id', searchField: d.searchField || null, match: d.match === 'startsWith' ? 'startsWith' : 'contains', sorters: sortField ? [{ field: sortField, direction: sortDir === 'desc' ? 'desc' : 'asc' }] : [], filters: [], }; return { ...base, ...root._config };}const labelOf = (cfg, record) => (typeof cfg.label === 'function' ? cfg.label(record) : String(record?.[cfg.labelField] ?? ''));const valueOf = (cfg, record) => (typeof cfg.value === 'function' ? cfg.value(record) : record?.[cfg.valueField]);/** the dataview request for a query and a page */function requestFor(cfg, query, page) { const field = cfg.searchField || cfg.labelField; return { query, filters: [...(cfg.filters || []), ...(query ? [{ field, op: cfg.match, value: query }] : [])], sorters: cfg.sorters || [], page, pageSize: cfg.pageSize, };}/** a loader's answer, whatever shape it came in: { rows, hasMore, total } */function normalize(answer, request) { const raw = Array.isArray(answer) ? { rows: answer } : answer || {}; const rows = raw.rows ?? raw.items ?? raw.data ?? raw.results ?? []; const total = Number.isFinite(raw.total) ? raw.total : undefined; const hasMore = typeof raw.hasMore === 'boolean' ? raw.hasMore : total !== undefined ? (request.page + 1) * request.pageSize < total : rows.length >= request.pageSize; return { rows, hasMore, total };}/** * The default network client: GET <url>?q=&page=&pageSize=&sort=field:dir * (or url(request) for your own URL), through client.fetch (default: * globalThis.fetch) with the AbortSignal, the JSON through client.parse. */async function networkLoad(cfg, request, signal) { const client = cfg.client || {}; let url; if (typeof cfg.url === 'function') url = cfg.url(request); else { const params = new URLSearchParams({ q: request.query, page: String(request.page), pageSize: String(request.pageSize) }); if (request.sorters[0]) params.set('sort', `${request.sorters[0].field}:${request.sorters[0].direction || 'asc'}`); url = `${cfg.url}${String(cfg.url).includes('?') ? '&' : '?'}${params}`; } const doFetch = client.fetch || globalThis.fetch.bind(globalThis); const response = await doFetch(url, { signal, headers: { accept: 'application/json', ...client.headers } }); if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'request failed'}`.trim()); const json = await response.json(); return client.parse ? client.parse(json, request) : json;}/** local records: a dataview source - filtered and sorted once per query, paged by slicing */function localLoad(root, cfg, request) { if (!root._source || root._source.rows !== cfg.rows) root._source = dataSource(cfg.rows, { idField: cfg.valueField }); const entries = root._source.query({ filters: request.filters, sorters: request.sorters }).entries; const start = request.page * request.pageSize; return { rows: entries.slice(start, start + request.pageSize).map((e) => e.row), total: entries.length };}/** one request through the configured loader: load(), else rows, else the network */function runLoad(root, cfg, request, signal) { if (typeof cfg.load === 'function') return cfg.load(request, { signal }); if (Array.isArray(cfg.rows)) return localLoad(root, cfg, request); if (cfg.url) return networkLoad(cfg, request, signal); throw new Error('autocomplete: no data - configure rows, url or load');}// -- the popup ---------------------------------------------------------------------/** the label with the query marked (text nodes only - never markup) */function markMatch(el, label, query) { el.textContent = ''; const at = query ? label.toLowerCase().indexOf(query.toLowerCase()) : -1; if (at < 0) { el.append(label); return; } const mark = document.createElement('mark'); mark.className = 'autocomplete-match'; mark.textContent = label.slice(at, at + query.length); el.append(label.slice(0, at), mark, label.slice(at + query.length));}/** append a page of records as options */function appendOptions(root, rows) { const cfg = configOf(root); const list = listOf(root); const query = root._run.query; for (const record of rows) { const index = root._run.records.length; root._run.records.push(record); const option = document.createElement('div'); option.className = 'autocomplete-option'; option.setAttribute('role', 'option'); option.id = `${root._uid}-opt-${index}`; option.dataset.index = String(index); option.setAttribute('aria-selected', 'false'); if (cfg.render) cfg.render(option, record, { query, index }); else { const label = document.createElement('span'); label.className = 'autocomplete-label'; markMatch(label, labelOf(cfg, record), query); option.append(label); } list.append(option); }}function setStatus(root, text) { const status = dfDollar(root).find('.autocomplete-status').get(0); if (status) status.textContent = text;}/** the counts line under the options */function describe(root) { const run = root._run; const n = run.records.length; if (!n) return; const num = (v) => v.toLocaleString(textLocale(root)); // the text's locale, never the browser's const total = run.total !== undefined ? ` of ${num(run.total)}` : ''; setStatus(root, run.loadingMore ? `${num(n)}${total} · loading more…` : `${num(n)}${total}${run.hasMore ? ' · scroll for more' : ''}`);}/** move the active option (keyboard / pointer), keep it in view */function activate(root, index) { const run = root._run; const n = run.records.length; if (!n) return; run.active = Math.max(0, Math.min(n - 1, index)); const input = inputOf(root); for (const option of dfDollar(listOf(root)).children('.autocomplete-option').toArray()) { const on = Number(option.dataset.index) === run.active; option.toggleAttribute('data-active', on); if (on) { input.setAttribute('aria-activedescendant', option.id); option.scrollIntoView({ block: 'nearest' }); } } // the end of the list is near: the next page if (run.active >= n - 3) loadMore(root);}// -- searching -------------------------------------------------------------------------/** cancel what is in flight (a newer query, a close) */function abort(root) { root._controller?.abort(); root._controller = null; clearTimeout(root._timer);}/** * Start a search for `query` (page 0): abort the previous request, show * 'loading', then 'open' / 'empty' / 'error' when THIS request answers (a * request a newer one replaced lands nowhere). */async function search(root, query) { abort(root); const cfg = configOf(root); const input = inputOf(root); root._run = { query, records: [], page: 0, hasMore: false, total: undefined, active: -1, loadingMore: false, seq: (root._run?.seq || 0) + 1 }; listOf(root).textContent = ''; input.removeAttribute('aria-activedescendant'); if (query.length < cfg.minChars) { autocompleteApi.setState(root, 'default'); return; } setStatus(root, 'Searching…'); autocompleteApi.setState(root, 'loading'); await fetchPage(root, 0);}async function fetchPage(root, page) { const cfg = configOf(root); const run = root._run; const seq = run.seq; const controller = new AbortController(); root._controller = controller; const request = requestFor(cfg, run.query, page); // Fires before every request (each page) - detail.request is the dataview request the loader receives. root.dispatchEvent(new CustomEvent<AutocompleteRequestDetail>('autocomplete-request', { bubbles: true, detail: { request } })); try { const answer = normalize(await runLoad(root, cfg, request, controller.signal), request); if (controller.signal.aborted || seq !== root._run.seq) return; // a newer query owns the list run.page = page; run.hasMore = answer.hasMore; run.total = answer.total; run.loadingMore = false; appendOptions(root, answer.rows); if (!run.records.length) { setStatus(root, ''); autocompleteApi.setState(root, 'empty'); return; } describe(root); if (root.store.value.name !== 'open') autocompleteApi.setState(root, 'open'); if (run.active < 0) activate(root, 0); // a short first page may not fill the list - keep going until it does const list = listOf(root); if (run.hasMore && list.scrollHeight <= list.clientHeight) loadMore(root); } catch (error) { if (controller.signal.aborted || error?.name === 'AbortError' || seq !== root._run.seq) return; run.loadingMore = false; root._error = error; if (run.records.length) { // a later page failed: keep what is there, say so setStatus(root, `Could not load more - ${error?.message || error}`); return; } setStatus(root, ''); autocompleteApi.setState(root, 'error', { message: String(error?.message || error) }); } finally { if (root._controller === controller) root._controller = null; }}/** infinite: the next page, once at a time */function loadMore(root) { const run = root._run; if (!run || !run.hasMore || run.loadingMore || root._controller) return; run.loadingMore = true; describe(root); fetchPage(root, run.page + 1);}/** take a record: the input shows its label, the value field (if any) its value */function choose(root, index) { const record = root._run?.records[index]; if (!record) return; const cfg = configOf(root); const label = labelOf(cfg, record); const value = valueOf(cfg, record); inputOf(root).value = label; const hidden = dfDollar(root).find<HTMLInputElement>('.autocomplete-value').get(0); if (hidden) hidden.value = value == null ? '' : String(value); abort(root); autocompleteApi.setState(root, 'default', { query: label, value: value ?? null, label }); // Fires when a suggestion is taken - its record, value and label. root.dispatchEvent(new CustomEvent<AutocompleteSelectDetail>('autocomplete-select', { bubbles: true, detail: { record, value, label } }));}// -- State API ---------------------------------------------------------------------------/** * UI side of setState: the states that show the popup open it (anchored to * the input), 'default' closes it. `{ query }` in a setState from outside * types that query (the search runs); `{ message }` is an error's text. */function triggerStateChange(root, state, incoming) { const popover = popoverOf(root); applyMarkup(root, state); if (popover) { if (SHOWN.has(state.name)) { if (!popover.matches(':popover-open')) safeShowPopover(popover); } else if (popover.matches(':popover-open')) { try { popover.hidePopover(); } catch { /* already closed */ } } } if (state.name === 'default') { abort(root); inputOf(root)?.removeAttribute('aria-activedescendant'); } if (state.name === 'error') setStatus(root, ''); const message = dfDollar(root).find('.autocomplete-error-text').get(0); if (message) message.textContent = state.name === 'error' ? String(state.config.message || 'Something went wrong.') : ''; // a query passed in from outside: show it and search for it // 'open' with nothing listed yet: the suggestions for what is typed const typed = inputOf(root)?.value.trim() ?? ''; if (state.name === 'open' && incoming.query === undefined && !root._run?.records.length && typed) { queueMicrotask(() => search(root, typed)); } if (typeof incoming.query === 'string' && SHOWN.has(state.name) && incoming.query !== root._run?.query) { inputOf(root).value = incoming.query; // after this state is recorded - the search moves on to 'loading' / 'open' queueMicrotask(() => search(root, incoming.query)); }}export const autocompleteApi = componentState({ component: 'autocomplete', states: autocompleteStates, // the config merges: a state change keeps the query and the chosen value mergeConfig: true, apply: (root, state, _previous, incoming) => triggerStateChange(root, state, incoming), markup: (el, state) => applyMarkup(el, state),});df$.autocompleteApi = autocompleteApi;df$.autocompleteStates = autocompleteStates;// -- df$.shadcn.autocomplete: the imperative surface ---------------------------------------const resolve = (target) => (typeof target === 'string' ? dfDollar(target).get(0) : target);df$.autocomplete = { /** * Bind data and behavior. Data (one of): rows (local records - a dataview * source), url (string or (request) => url; the default client GETs it * with ?q=&page=&pageSize=&sort=), load(request, { signal }) - your own * client, returning records or { rows, hasMore, total }. client: { fetch, * headers, parse(json, request) } customizes the default client. Query: * searchField, match ('contains' | 'startsWith'), sorters, filters, * pageSize, debounce (ms), minChars. Display: label / value (field names * or functions), render(option, record, { query, index }). * @param target - the .autocomplete element or its selector * @param config - data source, query and display options, merged into the current config */ configure(target: string | HTMLElement, config: AutocompleteConfig = {}): void { const root = resolve(target); root._config = { ...root._config, ...config }; if (config.labelField) root._config.labelField = config.labelField; root._source = null; }, /** * Search for a query now (no debounce): the input shows it and the list loads. * @param target - the .autocomplete element or its selector * @param query - the text to search for * @returns settles when the first page has loaded (or the request failed) */ search: (target: string | HTMLElement, query: string): Promise<void> => { const root = resolve(target); inputOf(root).value = query; return search(root, query); }, /** * Close the popup and cancel what is in flight. * @param target - the .autocomplete element or its selector */ close: (target: string | HTMLElement): void => { autocompleteApi.setState(resolve(target), 'default'); }, /** * The records the list holds now. * @param target - the .autocomplete element or its selector * @returns a copy of the loaded records, every page so far, in list order */ records: (target: string | HTMLElement): DataviewRow[] => [...(resolve(target)._run?.records ?? [])],};// -- init --------------------------------------------------------------------------------------function init() { dfDollar('.autocomplete:not([data-init])').toArray().forEach((root) => { root.dataset.init = ''; const input = inputOf(root); if (!input) return; root._uid = root.id || `autocomplete-${++uid}`; // the popup: authored, or made - a manual popover (focus stays in the input) let popover = popoverOf(root); if (!popover) { popover = document.createElement('div'); popover.className = 'autocomplete-popover'; dfDollar(root).append(popover); } popover.setAttribute('popover', 'manual'); if (!popover.id) popover.id = `${root._uid}-popover`; let list = listOf(root); if (!list) { list = document.createElement('div'); list.className = 'autocomplete-list'; dfDollar(popover).append(list); } list.setAttribute('role', 'listbox'); if (!list.id) list.id = `${root._uid}-list`; if (!list.hasAttribute('aria-label') && !list.hasAttribute('aria-labelledby')) list.setAttribute('aria-label', input.getAttribute('aria-label') || 'Suggestions'); if (!dfDollar(popover).find('.autocomplete-status').get(0)) { const status = document.createElement('div'); status.className = 'autocomplete-status'; status.setAttribute('role', 'status'); dfDollar(popover).append(status); } if (!dfDollar(popover).find('.autocomplete-error').get(0)) { const error = document.createElement('div'); error.className = 'autocomplete-error'; const text = document.createElement('span'); text.className = 'autocomplete-error-text'; const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'autocomplete-retry'; retry.textContent = 'Retry'; error.append(text, retry); dfDollar(popover).append(error); } if (!dfDollar(popover).find('.autocomplete-empty').get(0)) { const empty = document.createElement('div'); empty.className = 'autocomplete-empty'; empty.textContent = root.dataset.emptyText || 'No matches.'; dfDollar(popover).append(empty); } // the APG combobox wiring input.setAttribute('role', 'combobox'); input.setAttribute('aria-autocomplete', 'list'); input.setAttribute('aria-controls', list.id); input.setAttribute('autocomplete', 'off'); if (!input.hasAttribute('aria-expanded')) input.setAttribute('aria-expanded', 'false'); // anchored under the input (CSS anchor positioning) const anchor = `--autocomplete-${root._uid}`; input.style.anchorName = anchor; popover.style.positionAnchor = anchor; root._run = { query: '', records: [], page: 0, hasMore: false, active: -1, seq: 0 }; // the closed state's markup, from the start (data-state, aria-expanded) applyMarkup(root, { name: 'default' }); // typing: debounced search (a newer keystroke restarts the wait) - and // whatever is in flight is already stale: abort it now, not after the wait input.addEventListener('input', () => { abort(root); const query = input.value.trim(); const hidden = dfDollar(root).find<HTMLInputElement>('.autocomplete-value').get(0); if (hidden) hidden.value = ''; root._timer = setTimeout(() => search(root, query), configOf(root).debounce); }); input.addEventListener('keydown', (e) => { const name = root.store.value.name; const run = root._run; switch (e.key) { case 'ArrowDown': e.preventDefault(); if (name === 'default') search(root, input.value.trim()); else activate(root, run.active + 1); break; case 'ArrowUp': e.preventDefault(); if (name !== 'default') activate(root, run.active - 1); break; case 'PageDown': if (name !== 'default') { e.preventDefault(); activate(root, run.active + 10); } break; case 'PageUp': if (name !== 'default') { e.preventDefault(); activate(root, run.active - 10); } break; case 'Enter': if (name === 'open' && run.active >= 0) { e.preventDefault(); choose(root, run.active); } break; case 'Escape': // APG: Escape closes the popup; a second Escape clears the input if (name !== 'default') autocompleteApi.setState(root, 'default'); else if (input.value) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); } e.preventDefault(); break; case 'Tab': if (name !== 'default') autocompleteApi.setState(root, 'default'); break; } }); // pointer: pressing an option takes it (pointerdown keeps the focus in the input) list.addEventListener('pointerdown', (e) => { const option = (e.target as HTMLElement).closest?.<HTMLElement>('.autocomplete-option'); if (!option) return; e.preventDefault(); choose(root, Number(option.dataset.index)); }); list.addEventListener('pointermove', (e) => { const option = (e.target as HTMLElement).closest?.<HTMLElement>('.autocomplete-option'); if (option && Number(option.dataset.index) !== root._run.active) activate(root, Number(option.dataset.index)); }); // infinite: near the end of the list, the next page list.addEventListener('scroll', () => { if (list.scrollTop + list.clientHeight >= list.scrollHeight - 48) loadMore(root); }, { passive: true }); popover.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest?.('.autocomplete-retry')) search(root, root._run.query); }); // leaving the widget closes it root.addEventListener('focusout', (e) => { if (e.relatedTarget && root.contains(e.relatedTarget as Node)) return; if (root.store.value.name !== 'default') autocompleteApi.setState(root, 'default'); }); // el.store + el.api (AGENTS.md "State through stores") bindComponent(root, autocompleteApi, { name: 'default', config: { query: '', value: null, label: '' } }); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub