/** * Type-ahead entry over a local or remote option list. * * @packageDocumentation */ import type { ColumnDropdownOption } from '../../types/column.types'; import { type ValidationResult } from '../types/validation.types'; import { AbstractCellEditor, type EditorParams } from './base/abstract-editor'; /** * What an autocomplete cell commits. * * The array arm exists only for {@link AutocompleteEditorParams.multiple}. A * multi-select column should declare `type: 'array'`, which is the one column * type whose parser passes an array through intact; any other type stringifies * it on the way to the data. */ export type AutocompleteValue = string | number | null | readonly (string | number)[]; /** * The bag the {@link AutocompleteEditorParams.options} function form receives, * so a list can depend on the row being edited. */ export type AutocompleteOptionsContext = EditorParams; /** `cellEditorParams` for {@link AutocompleteEditor}. */ export interface AutocompleteEditorParams { /** * The local choices, either literal or computed per cell. * * Omit it and the editor falls back to `ColumnDef.dropdownOptions`, then to * `ColumnDef.enumOptions`. Can be combined with {@link fetchOptions}, which * replaces the list once a response arrives — useful for showing recent or * default choices before the user has typed anything. */ readonly options?: readonly ColumnDropdownOption[] | ((context: AutocompleteOptionsContext) => readonly ColumnDropdownOption[]); /** * Fetches choices for a query — the hook for a list too large to ship to the * browser. * * Calls are debounced by {@link debounceMs} and race-guarded, so only the * newest response is ever rendered. Results are shown exactly as returned, * *not* filtered again locally: the server has already decided what matches, * and re-filtering would quietly discard fuzzy or synonym hits it meant to * include. * * A rejection is not an error state — it empties the list and leaves the field * usable, because a failed lookup must never trap the user in a cell. */ readonly fetchOptions?: (query: string) => Promise; /** * Characters required before the list opens. * * @default 0 — a local list should show on first interaction. Raise it to 2 * or 3 for {@link fetchOptions}, where a one-character query matches most of * the table and costs a round trip to say so. */ readonly minChars?: number; /** * Quiet period before {@link fetchOptions} is called, in milliseconds. * * @default 200 — long enough that a typed word costs one request instead of * one per keystroke, short enough to feel immediate. */ readonly debounceMs?: number; /** * Accept text that matches no option. * * @default false, which is what makes this editor a *constrained* choice: the * value is guaranteed to be one of the options, and {@link * AutocompleteEditor.validate} blocks the commit otherwise. Set `true` for a * field where the list is a convenience rather than a domain — tags, a * free-form category. */ readonly freeSolo?: boolean; /** * Match case-sensitively. * * @default false. Users do not capitalise consistently, and a list that hides * the option they are looking at reads as broken. */ readonly caseSensitive?: boolean; /** * Cap on rendered options. * * @default 50. A list nobody can scan is not more useful for being longer, and * an unbounded filter over ten thousand options builds ten thousand * elements on every keystroke. */ readonly maxResults?: number; /** * Hint shown while the field is empty. * * @default 'Search…' — the box filters a list, and saying so is more use than * echoing a value the user can already see marked in the list below. Set it * to say something else when the column wants a more specific prompt * ("Search customers", a format example). */ readonly placeholder?: string; /** Message used when the text matches no option and `freeSolo` is off. */ readonly noMatchMessage?: string; /** Text shown in place of the list when nothing matches. */ readonly emptyText?: string; /** * Let the cell hold several options at once. * * Turns every option into a checkbox row, adds a check-all control under the * search box, and makes {@link AutocompleteEditor.getValue} return an array. * Choosing an option toggles it and leaves the list open — a multi-select * that closed on the first pick would make the second choice cost another * double-click. * * Declare the column as `type: 'array'`: it is the only type whose parser * passes an array through to the data unchanged. * * @default false */ readonly multiple?: boolean; /** Label on the check-all row. @default 'Select all' */ readonly selectAllText?: string; /** Text shown beside the spinner while {@link fetchOptions} is running. @default 'Loading…' */ readonly loadingText?: string; /** * Separator used to read a multi-select back out of a column that stores it * as text. * * Only a `type: 'array'` column keeps the array; every other type stringifies * it, and this is how the pieces are recognised again when the cell is * reopened. It must match what the stringified array produces, which is why * the default is a bare comma. * * @default ',' */ readonly separator?: string; /** * Where filtering happens. * * - `'client'` — the browser filters the list it already has. Typing costs * nothing and never shows a spinner. * - `'server'` — every keystroke goes to {@link fetchOptions}, debounced. * Correct when the list is too large to hold, and the only mode that can * find an option the browser has never seen. * - `'auto'` — `'server'` when a fetcher is configured, `'client'` otherwise. * * ### Why you may want `'client'` *with* a fetcher * A fetched list that fits in the browser is best fetched **once**, when the * editor opens, and filtered locally after that. Left in `'server'` mode, a * list already on screen still costs a round trip per keystroke, and the user * watches a spinner cover an option they can see. * * @default 'auto' */ readonly searchMode?: 'client' | 'server' | 'auto'; /** * Render only the options in view. * * Off, a list is built element by element; ten thousand options are ten * thousand nodes on every keystroke. On, the count of live nodes is bounded by * the height of the popup, so the list opens in the same time at ten options * or at a hundred thousand. * * @default true once the list passes {@link virtualScrollThreshold}, which is * where building it whole starts to be measurable. Set `false` to force the * simple path, `true` to virtualise from the first option. */ readonly virtualScroll?: boolean; /** * Option count above which {@link virtualScroll} switches itself on. * * @default 150 — comfortably more than any list a user scans by eye, and well * below where building the DOM whole becomes noticeable. */ readonly virtualScrollThreshold?: number; /** * Height of one option row in px, used to place the virtual window. * * Must match what the stylesheet renders, which the editor enforces by * applying it to the rows it builds. Change it together with any theme that * restyles `.pg-editor-option`. * * @default 32 */ readonly rowHeight?: number; /** * Fetches the next page when the list is scrolled to its end. * * The hook for a list nobody should download whole: return the next slice and * it is appended. Returning fewer rows than asked for — or none — ends the * sequence, so there is no separate "no more data" flag to keep in step. * * Pages are appended to the options already held, and the guard against * re-entry is internal: scrolling hard at the bottom cannot stack requests. * * @example * ```ts * loadMore: ({ query, offset, pageSize }) => * fetch(`/api/owners?q=${query}&skip=${offset}&take=${pageSize}`).then((r) => r.json()), * ``` */ readonly loadMore?: (context: { readonly query: string; /** How many options are already held. */ readonly offset: number; readonly pageSize: number; }) => Promise; /** Rows requested per {@link loadMore} call. @default 50 */ readonly pageSize?: number; } /** * A combobox: a text field that filters a list as you type, with full keyboard * operation and correct ARIA. * * ### Why it is a popup * The list is many rows tall and the grid clips its viewport, so an inline list * would be cut off at the first row boundary. Mounting in a portal is the only * way it can overlap the rows beneath it. * * ### Accessibility is the feature * A text input beside a styled `
` list is not a combobox to anyone using a * screen reader — it is an unlabelled text field next to some decorative text. * The wiring here is the ARIA 1.2 combobox pattern in full: `role="combobox"` * with `aria-expanded` and `aria-controls` on the input, a `role="listbox"` of * `role="option"` elements, `aria-selected` on the chosen one, and * `aria-activedescendant` naming the active option so arrowing through the list * is announced *without* focus ever leaving the field the user is typing in. * That last point is why the options are not focusable elements. * * `aria-activedescendant` is removed — not merely repointed — whenever there is * no active option, which includes every re-filter: the ids it names are rebuilt * on each keystroke, and an attribute left pointing at a deleted element leaves * assistive technology describing an option that is no longer on screen. A * visually-hidden live region carries the result count alongside it, because the * list shrinking as you type is otherwise a purely visual event. * * ### It opens empty, showing everything * The search box is *not* seeded with the cell's current value. Seeding it made * every session start with the list filtered down to the one option already * chosen, so browsing to a different one meant clearing the box first — the user * had to undo the editor's work before they could use it. Opening empty shows * the whole list immediately (subject to {@link * AutocompleteEditorParams.minChars}), and the current choice is not lost: it is * marked `aria-selected` in the list and remains what {@link getValue} returns * until the user picks or types something else. The box carries a `Search…` * hint rather than an echo of the value, because it is a filter and reading it * as pre-filled text was the more confusing of the two. An empty box therefore * means "unchanged", not "cleared" — clearing is typing something and deleting * it, which is the only gesture that can be told apart from never having typed * at all. * * ### Choosing is one gesture * Clicking an option, or pressing `Enter` on the highlighted one, selects it * *and* commits the cell — the editor closes and focus returns to the cell. * Anything less left the user having chosen but still stuck in the editor. With * {@link AutocompleteEditorParams.freeSolo} on, `Enter` over text that names no * option commits that text the same way. With the list closed and nothing * highlighted the key is left alone, so it keeps its grid-wide meaning. * * ### Escape closes the list before it cancels the edit * One `Escape` dismisses the list; a second cancels the session. Collapsing both * into one keystroke means a user who opened the list by accident loses their * edit to get rid of it — the same layered dismissal every native combobox uses. * * ### Remote lookups * `fetchOptions` is debounced and race-guarded by a monotonic token: a response * that arrives after a newer request was issued is dropped, so a slow query for * `"a"` can never overwrite the list for `"amsterdam"`. The pending timer and * the token are both cleared on {@link destroy}, so a session closed mid-flight * touches no DOM afterwards. * * @example * ```ts * { * field: 'customerId', * editable: true, * cellEditor: 'autocomplete', * cellEditorParams: { * minChars: 2, * debounceMs: 250, * fetchOptions: async (query) => { * const res = await fetch(`/api/customers?q=${encodeURIComponent(query)}`); * return res.json(); * }, * }, * } * ``` */ export declare class AutocompleteEditor extends AbstractCellEditor { private input; private listbox; /** * The check-all row, built only for {@link AutocompleteEditorParams.multiple}. * * Null in single-select mode rather than built-and-hidden: a hidden checkbox * is still in the accessibility tree's way, and the mode cannot change during * a session. */ private selectAll; /** * The visually-hidden live region carrying the result count. * * A separate element from the listbox on purpose: making the listbox itself * live would have every option re-announced on every keystroke, which is the * classic way a well-meaning `aria-live` renders a combobox unusable. */ private status; /** The full candidate list — local, or whatever the last fetch returned. */ private options; /** What the list is currently showing, and what the arrow keys walk. */ private matches; /** Index into {@link matches}, or `-1` for "no active option". */ private activeIndex; /** * The value the editor would commit if it closed right now — the cell's own * value until the user chooses a different option. * * Held separately from the field's text because the two are no longer the same * thing: the text is a *search query* that starts empty, while this is the * *selection* that starts at whatever the cell holds. It is what * `aria-selected` marks in the list, so a screen-reader user browsing the * options is still told which one is currently chosen. * * Unused in {@link AutocompleteEditorParams.multiple} mode, where * {@link selected} carries the selection instead. */ private selectedValue; /** * The chosen values in multi-select mode, keyed by their stringified form. * * A `Map` rather than a `Set` because two things are needed at once: * membership tested against the string form (a column may store `1` where its * option declares `'1'`, and a list that shows neither as ticked is the bug * users report), and the *original* typed values to commit. Insertion order is * the order the user picked them, which is the order they are written. */ private readonly selected; /** * Whether the user has typed in the search box at all this session. * * The one bit that separates "opened and left alone" from "deliberately * cleared", which an empty field alone cannot express now that the field opens * empty. Untouched, an empty field commits the cell's existing value; emptied * after typing, it commits `null`. Without this the mere act of opening a cell * and pressing `Enter` would wipe it — data loss caused by looking at a cell, * which is the worst class of grid bug. */ private textEdited; private open; /** * Whether a remote lookup is outstanding. * * Set when the request is *scheduled*, not when it is sent: the debounce * window is part of the wait as far as the user is concerned, and a list that * says "No matches" for 200ms before the spinner appears reads as an answer * rather than as a pause. */ private loading; /** Id of the listbox, and the stem of every option's id. */ private listId; private debounceTimer; /** Monotonic request token; only the newest response may render. */ private fetchToken; /** Set by {@link destroy}, so an in-flight response cannot touch dead DOM. */ private disposed; /** * Whether {@link AutocompleteEditorParams.fetchOptions} has answered once. * * The switch that makes client-side search possible over a remote list: the * first keystroke pulls the list, every one after it filters what arrived. * Left `false` by a failed lookup, so a dropped connection retries rather * than filtering an empty list for the rest of the session. */ private fetchedOnce; /** The query the currently-held pages belong to. @see AutocompleteEditorParams.loadMore */ private pagedQuery; /** `true` once {@link AutocompleteEditorParams.loadMore} has run dry. */ private exhausted; /** Guards against stacking page requests while one is in flight. */ private loadingMore; /** The scrolling window's spacer and viewport, when the list is virtualised. */ private virtualSpacer; private virtualWindow; /** First option index currently in the DOM, for the virtual path. */ private windowStart; /** Larger than a cell, so it is mounted in a portal above the grid. */ isPopup(): boolean; protected buildGui(): HTMLElement; /** Whether this session is a multi-select one. */ private isMultiple; /** * Reads the cell's value into the selection. * * Three shapes have to be understood, because all three are what a cell can * legitimately hold: * * - an **array**, from a `type: 'array'` column, which passes through intact; * - a **delimited string**, from every other column type — `parseValue` runs * `String(raw)` over the committed array, so `['a','b']` comes back as * `"a,b"`. Reading that as one opaque value is what made a reopened * multi-select show nothing ticked, losing the user's selection the moment * they looked at the cell again; * - a **lone value**, so a column switched to `multiple` after the fact still * opens with what it already held. * * Entries are matched against the option list where possible, so a value that * legitimately contains the separator is not split apart behind the user's * back — the whole string wins when it names an option. */ private seedSelection; /** Normalises whatever the cell holds into the list of chosen values. */ private readMultiValue; /** * The committed value: every ticked option in multi-select mode, and * otherwise the matched option's value, the raw text, or — for a search box * the user never touched — the cell's existing value. * * That last case is what makes an empty box mean "unchanged" rather than * "cleared"; see {@link textEdited} for why the distinction has to be tracked * rather than read off the field. Raw text reaches the data only when * `freeSolo` is on — otherwise {@link validate} has already blocked the commit. */ getValue(): AutocompleteValue; /** * Rejects text that names no option, unless the column opted into free entry. * * Editor-local rather than a column rule because only the editor knows what * its list currently holds — after a remote fetch, that is not something a * declarative rule could have been given up front. */ validate(): ValidationResult; focus(): void; /** * Opens the list once the popup is placed, so the choices are visible without * the user having to discover the arrow keys. * * Deferred to this hook rather than done in `init` because a list opened * before the popup has been measured and positioned would paint at the wrong * place first. */ afterGuiAttached(): void; /** Builds the text field, with the combobox half of the ARIA wiring. */ private buildInput; /** * Builds the list. * * One delegated `mousedown` listener serves every option — N listeners for N * options would be rebuilt on each keystroke — and it suppresses its default * action so the click never pulls focus out of the field, which would close * the session before the selection landed. * * Picking with the pointer *commits*, exactly as `Enter` on a highlighted * option does: clicking an option is the whole gesture, and leaving the editor * open on a list the user has just finished with made choosing take a click * and then a second, unrelated action to get out of the cell. */ private buildListbox; /** * Builds the check-all row shown under the search box in multi-select mode. * * ### It acts on what is *shown*, not on everything * Ticking it selects the options the search has narrowed the list down to. * That is what makes it useful — "search `2024`, take all of them" — and it is * the only reading under which the control can honestly show its own state: * with a remote list, "everything" is a set the browser has never seen. * * ### Why the press is swallowed * `mousedown` is prevented so the click cannot pull focus out of the search * field. The user is mid-search; losing the caret to a checkbox means the next * character they type goes nowhere. Keyboard users still reach it by `Tab`, * where moving focus is exactly what was asked for. */ private buildSelectAll; /** `true` when every option currently shown is already ticked. */ private allShownSelected; /** * Builds the live region that speaks the result count. * * `polite` rather than `assertive`: the count is context, and interrupting the * character the user just typed to deliver it is worse than saying nothing. * `aria-atomic` makes the whole sentence re-read, since a diff of "3" against * "2" would otherwise be announced as a bare number. It is hidden by * `pg-editor-sr-only`, never by `hidden` or `display: none`, which would remove * it from the accessibility tree and silence it entirely. */ private buildStatus; /** * Publishes the result count, skipping a repeat of what is already there. * * The guard is not a micro-optimisation: rewriting a live region with the same * text makes some screen readers announce it again, so a keystroke that does * not change the count would say "3 results available" twice. */ private announce; /** * The text the field opens with: the character that started a `'type'` * session, and otherwise **nothing**. * * Deliberately not the cell's current label — see the class note on why * seeding the search box made the editor harder to use than an empty one. The * current value is preserved by {@link selectedValue} and surfaced by * {@link currentLabel} as the placeholder, so nothing is hidden by leaving the * box empty. */ private initialText; /** Resolves the local list — params, then `dropdownOptions`, then `enumOptions`. */ private resolveOptions; /** * Reports the keystroke to the grid, then updates the list. * * Also the only place {@link textEdited} is set: this event fires for typing, * pasting and cutting, and for nothing the editor itself does to the field — * which is exactly the definition the flag needs. */ private onInput; /** * Recomputes what the list shows for `query`. * * Which of the two paths it takes is {@link AutocompleteEditorParams.searchMode}'s * decision, not the presence of a fetcher: a client-mode column with a fetcher * pulls its list **once**, when the editor opens, and filters it in the browser * from then on. Routing every keystroke to the server in that case is what put * a spinner over an option the user could already see. */ private refreshMatches; /** Whether each keystroke goes to the server. @see AutocompleteEditorParams.searchMode */ private serverSearch; /** Restarts the debounce window; only the last keystroke of a burst fetches. */ private scheduleFetch; /** * Runs one remote lookup and renders it only if it is still the newest — see * the class note on race guarding. */ private runFetch; /** * Puts the list into its waiting state: open, showing a spinner, and holding * whatever it showed before out of the way. * * The list is *opened* here, which is the point of the whole thing — a * dropdown whose first paint is 1.5 seconds of nothing gives the user no sign * their double-click registered, and the second double-click they try lands on * an editor that is already open. * * The previous matches are dropped so the arrow keys cannot walk a list that * is about to be replaced, and the check-all is disabled because there is * nothing on screen for it to act on. */ private showLoading; /** Local substring filter over label and value. */ private filter; /** Trims a list to {@link AutocompleteEditorParams.maxResults}. */ private capped; /** * How many matches the list will hold. * * The cap exists because building ten thousand rows on every keystroke is * what makes a dropdown feel broken — but that is a cost virtualisation * removes, so a virtualised list keeps every match instead of hiding the * ones past an arbitrary line. An explicit `maxResults` is always obeyed: * asking for a cap and being given an unbounded list would be the surprising * reading. */ private resultLimit; /** * The option `text` names exactly, by label or by value. * * Searched over the *full* option list rather than the visible matches: the * user may have picked an option and then closed the list, and the committed * value must still resolve. */ private findExactOption; /** * The combobox key map. * * Only keys the open list actually consumes are stopped; everything else keeps * its grid-wide meaning, which is what lets `Enter` commit and `Tab` navigate * from a closed combobox exactly as they do from a text editor. */ private onKeyDown; /** * `Enter`: selects and commits in one press, or steps aside. * * ### Why it commits rather than only selecting * Selecting alone left the editor open on a list the user had just finished * with, so committing took a second `Enter` — the same "two Enters" complaint * the native pickers produced. One keystroke picking *and* closing is what a * spreadsheet does, and it is why the event must be stopped here: left to * bubble, the grid's own `Enter` binding would commit a second time. * * ### The three cases * An option is highlighted — take it and close. `freeSolo` is on and the text * names no option — take the text and close, because there is nothing in the * list to wait for. Anything else — do nothing, so `Enter` keeps its grid-wide * meaning and the grid commits (or, for text matching no option with * `freeSolo` off, {@link validate} blocks it and says why). */ private onEnter; /** Moves the active option by `delta`, wrapping at both ends. */ private moveActive; /** * Commits a choice to the field and, when `commit` is set, closes the session * with it. * * {@link selectedValue} moves with it, so a later empty field still commits * what the user picked rather than reverting to what the cell held on open, * and the list marks the right option as selected if it is reopened. * * @param index - Index into {@link matches}. * @param commit - Whether choosing also ends the edit. `true` for the two * gestures that *are* the choice — clicking an option, `Enter` on the * highlighted one. `false` for `Tab`, which takes the highlighted option * with it but belongs to the grid's navigation, not to this editor. */ private choose; /** * Adds or removes one option from the multi-select, in place. * * The row is re-marked rather than the list rebuilt: rebuilding would reset * the scroll position and the active option, so ticking the fifth of twenty * would throw the user back to the top of a list they are working down. */ private toggleSelected; /** * Ticks or unticks every option currently shown. * * Scoped to {@link matches} — see {@link buildSelectAll} for why "all" means * "all of these". Untouched options outside the current filter keep whatever * state they had, so narrowing the search cannot silently discard a choice * made before it. */ private toggleAll; /** * Re-derives the check-all row from the options on screen. * * Three states, not two: `indeterminate` is what distinguishes "some of these * are ticked" from "none are", and a checkbox that showed only the two would * claim an empty selection every time one option was unticked. Disabled while * there is nothing shown — during a fetch, or on an empty result — because * "select all of nothing" is not an action. */ private syncSelectAll; /** * Renders `matches` and opens the list. * * Two strategies, chosen by size. A short list is built whole — simplest, and * the DOM is touched once through a fragment. A long one is *virtualised*: a * spacer holds the full scroll height and only the rows in view exist, so the * live node count is bounded by the popup's height rather than by the option * count, and a hundred thousand options open as fast as ten. * * Neither path diffs. The set changes wholesale on every keystroke, so a diff * would cost more than it saved. */ private showMatches; /** Whether a list of `count` options should be windowed rather than built whole. */ private virtualised; /** Row height in px, as both the stylesheet and the window maths must see it. */ private rowHeight; /** Empties the list and forgets any virtual scaffolding it was using. */ private resetList; /** * Builds one option row. * * `aria-setsize` and `aria-posinset` are stated explicitly because the virtual * path has only a handful of rows in the DOM at a time — without them a screen * reader announces "option 3 of 12" over a list of twelve thousand. */ private buildOption; /** * Puts the list into its windowed form: a full-height spacer, and a positioned * viewport holding only the rows that can be seen. * * The scaffolding carries `role="presentation"` so the two extra elements do * not appear between the listbox and its options in the accessibility tree — * a `role="listbox"` whose children are anonymous `
`s exposes no options * at all. */ private renderVirtual; /** * Renders the slice of options the current scroll position exposes. * * Skipped when the window has not moved, which is what keeps a smooth scroll * from rebuilding the same rows on every frame the browser fires. */ private renderWindow; /** * Handles a scroll of the option list: re-window, and ask for the next page * when the end comes into view. */ private onListScroll; /** * Requests the next page once the list is scrolled near its end. * * Guarded three ways, because a scroll handler fires far faster than a network * round trip: nothing while a page is already in flight, nothing once the * source has run dry, and nothing while the initial lookup is still going. */ private maybeLoadMore; /** * Highlights one option and points `aria-activedescendant` at it. * * In the virtual path the row may not exist yet, so the list is scrolled to it * and the window re-rendered first — arrowing past the bottom of a windowed * list would otherwise highlight nothing. */ private setActive; /** The rendered row for an option index, if it is currently in the DOM. */ private findRow; /** Scrolls a windowed list so `index` sits inside the viewport. */ private scrollIndexIntoView; /** Hides the list and clears the active option. */ private closeList; /** Single point of truth for the open state, the attribute and the DOM. */ private setOpen; } //# sourceMappingURL=autocomplete-editor.d.ts.map