@llselect/core
Version:
Low-level select: a minimal, flexible, framework-agnostic replacement for the native HTML select element.
1,553 lines • 79.7 kB
TypeScript
import { type LLSelectWidthPolicy } from './positioning.js';
import type { LLSelectUiTranslationPack } from './i18n.js';
/**
* What happens when the user clicks outside an open popup.
*
* - `'pass-through'` (default): close the popup; the outside click still
* triggers its normal action (button click, link navigation, etc.).
* - `'block'`: close the popup only; the outside click is swallowed so no
* underlying handler or default action fires. Avoids accidental side
* effects when the user only intended to dismiss the dropdown.
* @group Settings
* @category Base
*/
export type LLSelectOutsideClickBehavior = 'pass-through' | 'block';
/**
* Who initiated a chosen-state change, delivered to `onChange` as
* `meta.source`.
* - `'user'`: a pointer or keyboard interaction inside the widget - an
* option toggle, a tag's remove button, the clear button, the choose-all
* row.
* - `'api'`: any programmatic call - `setChosenItem` / `setChosenItems`,
* `toggleItem`, the `choose*` bulk ops, `setItems` reconciliation.
* @group Events
*/
export type LLSelectChangeSource = 'user' | 'api';
/**
* Extra facts about one `onChange` firing, as the callback's third argument.
* An object on purpose: future fields can be added without breaking the
* callback signature.
* @group Events
*/
export interface LLSelectChangeMeta {
source: LLSelectChangeSource;
}
/**
* Resolved (defaults applied) settings shared by all select variants.
* Subclasses (`LLSelectSingle`, `LLSelectMultiple`) extend this with their
* mode-specific options such as `onChange`.
*
* Settings are frozen after the constructor. What still changes at runtime:
* - State changes by method, and never was a setting: the items
* (`setItems`), the chosen value (`setChosenItem` / `setChosenItems`),
* `disabled` (`setDisabled`).
* - Two settings have a setter, because they are text: `uiTranslationPack`
* (`setUiTranslationPack`) and `placeholder` (`setPlaceholder`).
* - Every other setting is fixed for the instance's lifetime.
* - To change a fixed setting, build a new instance. One build takes about
* 0.2 ms.
* - If a setting must vary at runtime, use its function form where one
* exists. `filterable: (items) => boolean` is re-evaluated on every open
* (and consulted by closed-state typeahead - see the setting).
* @group Settings
* @category Base
*/
export interface LLSelectBaseSettings<T, GroupKey = string> {
/**
* Prefix used for every CSS class and DOM id the library generates
* (default `'llselect'`). NOTE: the shipped themes target the default
* prefix only - a custom prefix means bringing your own CSS. Reference the
* resolved names via `instance.classIdMap` instead of hardcoding strings.
* @group CSS
*/
cssClassPrefix: string;
/**
* Text shown in the trigger when nothing is selected. App copy: an explicit
* value always wins; when unset, the locale default
* `uiTranslationPack.triggerPlaceholder` is used (`'Please select'` in English).
* @group Trigger
*/
placeholder: string;
/**
* Accessible name of the field, like the `<label>` text of a native
* `<select>` (e.g. `'Country'`).
* - Applied to the trigger, the popup listbox, and (while the filter is active)
* the filter input; per-mode wiring: `docs/llm/A11Y.md` "Accessible name".
* - The name resolves by the FIRST set rung, mirroring the W3C
* accessible-name computation order:
* 1. `ariaLabelledBy`.
* 2. `ariaLabel` (this setting).
* 3. `labelEl` - its element's id becomes the resolved `ariaLabelledBy`.
* 4. None set: the field is unnamed. A combobox requires a name
* (WAI-ARIA 1.2), so one `console.warn` per page reports the first
* offender.
* - `null` (default): this rung is skipped. An empty or whitespace-only
* string counts as unset too - the accname computation skips a blank
* `aria-label`, and so does the ladder.
* @group Accessible name
*/
ariaLabel: string | null;
/**
* Space-separated DOM id(s) of the visible label element(s) naming the
* field; forwarded as `aria-labelledby` to the same elements as `ariaLabel`.
* - Prefer this over `ariaLabel` when a visible label element exists: the
* spoken name then always matches the visible text.
* - `null` (default): not forwarded; see `ariaLabel` for the naming
* requirement. An empty or whitespace-only string counts as unset too,
* same as `ariaLabel`.
* - Rung 1 of the resolution order (the numbered list at `ariaLabel`): it
* wins whenever set, matching the ARIA name computation.
* @group Accessible name
*/
ariaLabelledBy: string | null;
/**
* The widget's visible label element - the one foreign element the library
* touches. Emulates native `<label for>` (which cannot target these divs)
* in both directions:
* - clicking it focuses the trigger (focus ONLY; native `<select>` does not
* open on label click, neither does this);
* - it feeds the accessible name as rung 3 of the resolution order (the
* numbered list at `ariaLabel`): with neither aria setting given, the
* label's id becomes the resolved `ariaLabelledBy` (an id is minted from
* `classIdMap.labelId` if the element has none) - a live reference, so
* later label text changes stay correct.
* - `null` = no label element; the name ladder just skips this rung.
* - `destroy()` removes the click listener and a minted id.
* @group Accessible name
*/
labelEl: HTMLElement | null;
/**
* Equality predicate for item values - return `true` when `a` and `b` are the
* same item.
* - Required for non-primitive `T`. The default compares by identity:
* `===`, except that `NaN` equals `NaN` (SameValueZero, the same rule
* `Set` uses), so every code path agrees on what "the same item" means.
* - Used for selection, dedup, and matching the chosen item back to the list.
* - Symmetric: do not depend on which argument is the candidate vs the
* existing item.
* @group Items
*/
compareFn: (a: T, b: T) => boolean;
/**
* See {@link LLSelectOutsideClickBehavior}.
* @group Popup
*/
outsideClickBehavior: LLSelectOutsideClickBehavior;
/**
* The trigger arrow slot's content ELEMENT (typically a dropdown chevron or
* triangle). Called whenever the arrow may need to change - including on
* every open/close - so the returned element can vary with `isOpened`.
* - fn returns `null` - no arrow for that state.
* - setting is `null` (default) - the library adds nothing to the arrow slot.
* @group Trigger
*/
createTriggerArrowContentElFn: ((state: {
isOpened: boolean;
}) => HTMLElement | SVGElement | null) | null;
/**
* Whether the trigger shows a clear (x) button that empties the selection.
* - Default `false`.
* - Clearing sets the empty value: `undefined` for a single select, `[]`
* for a multiple. It goes through the normal setters, so `onChange`
* fires with that empty value. There is no separate clear event.
* - The empty value is not configurable, and no library makes it so:
* "nothing chosen" already exists before the first choice, so the value
* types carry `undefined` either way.
* - If your model is a plain type like `string` and must never hold
* `undefined`, pick one of these:
* - Add a real "none" item (for example `''` shown as "(none)") and skip
* `clearable`. The model then stays `string` after the first choice,
* exactly like a native `<select>` with a placeholder option.
* - Keep `clearable` and coerce in `onChange`: `item ?? ''`.
* - "Clear" means back to empty and the placeholder, never "back to some
* default option". If you want a default instead, set it yourself in
* `onChange`.
* - The button sits in its OWN trigger slot (like the arrow, so it never
* collides with `createTriggerContentElFn`), is `tabindex="-1"`, and
* carries an `aria-label`. The theme hides it via `data-empty` while
* nothing is selected.
* @group Trigger
*/
clearable: boolean;
/**
* Content ELEMENT of the clear button (its x icon), mirroring `createTriggerArrowContentElFn`.
* `null` (default) = the theme's CSS glyph. The library always owns the button,
* its click (clears + stops propagation) and aria; this only fills the icon.
* @group Trigger
*/
createTriggerClearButtonContentElFn: (() => HTMLElement | SVGElement | null) | null;
/**
* Whether the popup includes a filter input.
* - `false` (default): never.
* - `true`: always.
* - Predicate `(items) => boolean`: conditional - evaluated against the
* CURRENT full item list each time the popup OPENS (never mid-open; a
* `setItems` crossing the threshold applies on the next open). E.g.
* `filterable: (items) => items.length > 10`. A printable key pressed
* while CLOSED also calls the predicate, read-only, to decide whether
* prefix typeahead may take the key - keep it cheap and side-effect free.
* The ARIA mode follows the evaluated value per open cycle: active =
* trigger `role="button"`, focus moves to the input; inactive = exactly
* like `filterable: false` (trigger stays `role="combobox"`, focus stays on
* the trigger). See `docs/llm/A11Y.md` and `docs/llm/DESIGN.md`.
* @group Filtering
*/
filterable: boolean | ((items: readonly T[]) => boolean);
/**
* Chrome strings (AT labels + generated text): the i18n customization point. Resolved
* against English: pass a language pack whole (`uiTranslationPack: zhTW` from
* `@llselect/core/i18n`) or override single keys
* (`uiTranslationPack: { ...zhTW, filterInputPlaceholder: '...' }`).
* Key-by-key contract (incl. what `null` means where allowed):
* {@link LLSelectUiTranslationPack}.
* @group i18n
*/
uiTranslationPack: LLSelectUiTranslationPack;
/**
* Predicate used by the filter input; return `true` to keep the item.
* `null` (default) means the built-in case-insensitive substring match
* against the item's resolved text (`itemToStringFn` / `itemToString`).
* Pass a custom function for fuzzy / domain-specific matching.
* - `query` is the RAW input value: not trimmed and not lower-cased. Normalize
* it yourself (the built-in lower-cases both sides; it does not trim).
* - Not called while the query is empty (an empty box shows every item), but a
* whitespace-only query (e.g. `" "`) does call it.
* @group Filtering
*/
filterFn: ((item: T, query: string) => boolean) | null;
/**
* The no-results message's visible content ELEMENT, without subclassing.
* Mirrors `createItemContentElFn`: the library owns the message container
* (`role="status"`, class, show/hide), this fills its content only.
* - `query` is the current filter query (`''` when the filter is inactive or
* the list is simply empty), so "Nothing matches <query>" is possible.
* - Return an `HTMLElement`: inserted as the content (you own it; include
* real text - the status region announces its TEXT content).
* - `null` (setting default, or returned): plain text from
* `uiTranslationPack.popupListNoResults`.
* Re-evaluated every time the message is shown (the query may differ). The DOM
* is refreshed only when the resolved TEXT changes - the status region is keyed
* on text so it announces once, not per keystroke - so rich content whose visible
* markup varies while its text stays constant is not re-rendered.
* @group Filtering
*/
createPopupListNoResultsContentElFn: ((query: string) => HTMLElement | null) | null;
/**
* How the popup decides its width. Does NOT affect the trigger - trigger
* width is always whatever your CSS says.
*
* - `'fit-content'` (default): popup width grows to its own content (items,
* filter input, ...) and never shrinks below the trigger's width - the
* native `<select>` dropdown behavior, minus its viewport overflow:
* auto-shifts and width-clamps when the natural width would not fit.
* Direction-aware: in an RTL context
* (`getComputedStyle(trigger).direction === 'rtl'`, read once per open)
* it right-aligns to the trigger and grows LEFTWARD, the mirror of LTR.
* - `'match-trigger'`: popup width equals trigger width; long item text wraps
* inside the popup.
* @group Popup
*/
popupWidthPolicy: LLSelectWidthPolicy;
/**
* Predicate deciding whether an individual item is disabled. `null` (default)
* = nothing disabled. A disabled item is not selectable (click / Enter) and is
* skipped by keyboard nav; it keeps `role="option"` plus `aria-disabled`.
* Re-evaluated on every render (never cached). For a generic `T` this is the
* only way to mark items - the library cannot read a `disabled` field off an
* unknown type. See `docs/llm/DESIGN.md`.
* @group Disabling
*/
itemDisabledFn: ((item: T) => boolean) | null;
/**
* When the control is disabled via `setDisabled(true)`, whether the trigger
* stays in the tab order (`tabindex="0"`). `false` (default) takes it out
* (`-1`). Set `true` so keyboard / AT users can focus the disabled control to
* read a "why disabled" tooltip.
* @group Disabling
*/
focusableWhenDisabled: boolean;
/**
* Item -> display string, without subclassing.
* - `null` (default) = `String(item)`.
* - Read by the `itemToString` method's default; used for list text, the
* single trigger text, the option's accessible name, and the default
* filter. Inserted as `textContent` (plain text, NOT parsed as HTML).
* - For rich content (icons etc.), pass `createItemContentElFn`.
* @group Items
*/
itemToStringFn: ((item: T) => string) | null;
/**
* Item -> the visible content ELEMENT of its list row, without subclassing.
* - Return an `HTMLElement` and the library inserts it as-is (you own the
* node); it becomes the row's visible content.
* - `null` = plain `textContent` from `itemToString`. This is the default,
* both when the setting is unset and when your function returns `null` for
* a particular item.
* - Fills the VISIBLE content only. You never touch `aria-*`: when this
* returns an element the library sets the option's `aria-label` from
* `itemToString`, so the accessible name + match text stay owned by
* `itemToString` no matter what you render (icon-only, reordered, ...).
* To make the spoken/matched text differ from the visible content, set the
* two independently: `itemToStringFn` for the name/matching,
* `createItemContentElFn` for the look.
* - For full control of the option element (tag / wiring), subclass
* `createItemEl` instead.
* - Runs per rendered row per render, and re-runs whenever a row is
* rebuilt: open, filter, `setItems`, AND chosen-state changes (both modes
* replace the affected rows in place while the popup is open). Content
* that reads selection state (e.g. a checkmark on the chosen row via
* `createCheckmarkSvgEl`) therefore stays fresh; keep the function cheap.
*
* @example
* // List shows an icon + the item text; screen readers announce just that text.
* itemToStringFn: (lang) => lang.name,
* createItemContentElFn: (lang) => {
* const row = document.createElement('span')
* const icon = document.createElement('i')
* icon.className = `mdi mdi-${lang.icon}`
* icon.setAttribute('aria-hidden', 'true') // decorative
* row.append(icon, lang.name)
* return row
* }
* @group Items
*/
createItemContentElFn: ((item: T) => HTMLElement | null) | null;
/**
* Item -> its group's key (identity), enabling optgroup rendering.
* - `null` (setting, default): grouping off - flat list, no headers.
* - fn returns `null`: this item is in no group; renders ungrouped.
* - Contiguous items with an equal key (per `groupKeyCompareFn`) form one
* group. By default non-contiguous data is first gathered into display
* order (`gatherGroups`); with `gatherGroups: false` the data must be
* pre-sorted by group. See `docs/llm/DESIGN.md`.
* @group Grouping
*/
itemToGroupKeyFn: ((item: T) => GroupKey | null) | null;
/**
* Whether the library gathers non-contiguous groups before rendering
* (`true`, default). Grouping renders contiguous runs, so scattered items
* sharing a key would otherwise produce a duplicate header per gap.
* - `true`: the DISPLAY order is derived via {@link gatherItemsByGroupKey}:
* groups in first-appearance order, within-group order kept, ungrouped
* (`null`-key) items in place. The data itself (`items` / `getItems()`)
* is never reordered, and already-contiguous data is detected in one
* scan and used as-is.
* - `false`: strict mode - you guarantee the data is pre-sorted by group; a
* key reappearing after a gap renders a duplicate header and
* `console.warn`s, so a broken sort is surfaced instead of silently
* fixed.
* No effect while grouping is off (every key `null`).
* @group Grouping
*/
gatherGroups: boolean;
/**
* Equality for two group keys; decides whether items share a group (both
* the `gatherGroups` gather and the contiguous-run rendering use it).
* - `null` (default) = identity, the same rule as the default `compareFn`
* (`===`, plus `NaN` equals `NaN`); right for string / number keys.
* - Supply only when `GroupKey` is an object without usable reference identity.
* - Mirrors `compareFn`, one level up.
* @group Grouping
*/
groupKeyCompareFn: ((a: GroupKey, b: GroupKey) => boolean) | null;
/**
* Group key -> the header's display text. The i18n customization point: keep keys stable,
* translate here.
* - `null` (default) = `String(groupKey)`.
* @group Grouping
*/
groupKeyToStringFn: ((groupKey: GroupKey) => string) | null;
/**
* Predicate: is this whole group disabled?
* - `null` (default) = no group disabled.
* - `true` = every item in the group is treated as disabled (layers on top of
* `itemDisabledFn`).
* @group Grouping
*/
groupDisabledFn: ((groupKey: GroupKey) => boolean) | null;
/**
* Group header -> its visible content ELEMENT (icon / count badge / rich
* markup), without subclassing. Mirrors `createItemContentElFn`.
* - Return an `HTMLElement` and the library inserts it as the header's visible
* content; the group's accessible name stays `groupKeyToString` (on the
* container `aria-label`) and the label element stays `aria-hidden`.
* - `null` (default, or returned for a group) = plain text from
* `groupKeyToString`.
* - `itemsInGroup` is the group's items, so you can render "Fruits (4)" or a
* summary without recomputing the grouping.
* @group Grouping
*/
createGroupLabelContentElFn: ((groupKey: GroupKey, itemsInGroup: readonly T[]) => HTMLElement | null) | null;
/**
* Fired right after the popup opens. An `open()` call that does not actually
* open the popup (already open, a disabled control, or a trigger scrolled out
* of view or clipped) does not fire it. Fires in ADDITION to the protected
* `onOpened` hook - the setting is for consumers, the hook for subclasses;
* both run. `null` (default) = nothing.
* @group Events
*/
onOpen: (() => void) | null;
/**
* Fired right after the popup closes. A no-op `close()` does not fire it.
* Additive with the protected `onClosed` hook, like {@link onOpen}.
* @group Events
*/
onClose: (() => void) | null;
}
/**
* Constructor input for a resolved settings bag `S`: every field optional,
* and `uiTranslationPack` accepts a PARTIAL pack (missing keys fall back to
* English). Shared by the base / single / multiple `*SettingsInput` types;
* use it for a subclass wrapper that extends the settings bag.
* @group Settings
* @category Base
*/
export type LLSelectSettingsInputOf<S extends {
uiTranslationPack: LLSelectUiTranslationPack;
}> = Partial<Omit<S, 'uiTranslationPack'>> & {
uiTranslationPack?: Partial<LLSelectUiTranslationPack>;
};
/**
* Constructor-time settings input - every field is optional and missing
* fields fall back to the library defaults.
* @group Settings
* @category Base
*/
export type LLSelectBaseSettingsInput<T, GroupKey = string> = LLSelectSettingsInputOf<LLSelectBaseSettings<T, GroupKey>>;
/**
* Resolved CSS class names and DOM ids for one instance. Exposed on
* `instance.classIdMap` so callers can reuse them in their own CSS or query
* selectors instead of hard-coding the strings.
* @group CSS & DOM
*/
export interface LLSelectClassIdMap {
/** Class on `rootEl` (the caller-passed mount element). */
rootClass: string;
/**
* Class on `triggerEl` (the interactive trigger). Its `role` is
* `combobox` while the filter is inactive and `button` while a filterable popup
* is open; see `docs/llm/A11Y.md`.
*/
triggerClass: string;
/** Class on the inner span where content (text/tags) is rendered. */
triggerContentClass: string;
/** Class on the inner span where the optional dropdown arrow lives. */
triggerArrowClass: string;
/** Class on the clear (x) button slot in the trigger (`clearable`). */
triggerClearButtonClass: string;
/** Class on `popupEl` (the outer popup wrapper, no ARIA role). */
popupClass: string;
/** Class on `popupListEl` (the inner element with `role="listbox"`). */
popupListClass: string;
/**
* Class on the no-results message element (`role="status"`), shown below
* the (empty) listbox when the visible item list has zero entries.
*/
popupListNoResultsClass: string;
/**
* Class on the choose-all leading row (`LLSelectMultiple`, `chooseAllRow`
* setting). Also carries `itemClass` plus `data-chosen-state="none|some|all"`
* for the tri-state visual.
*/
chooseAllRowClass: string;
/** Class on every item element (`role="option"`) inside the popup list. */
itemClass: string;
/**
* Extra class added to the currently keyboard-focused item element.
* Use this to style the focused item.
*/
itemFocusedClass: string;
/**
* Class added to a disabled item element (which also carries
* `aria-disabled="true"`). A stable hook for styling / tooltip targeting.
*/
itemDisabledClass: string;
/**
* Class on a group container (`role="group"`). A disabled group's container
* also carries `aria-disabled="true"` + `data-disabled="true"`.
*/
groupClass: string;
/**
* Class on the visible group label element (`aria-hidden`), inside the group
* container above its items. A hook for styling / sticky headers.
*/
groupLabelClass: string;
/** Class on the tag-list container in `triggerDisplay: 'tags'` mode (multi). */
tagsClass: string;
/** Class on one tag chip (`triggerDisplay: 'tags'`). */
tagClass: string;
/** Class on a tag's remove (x) button; `aria-label` names the item, `tabindex="-1"`. */
tagRemoveButtonClass: string;
/**
* Class on a tag chip whose item is effectively disabled (which also carries
* `aria-disabled="true"`, and whose x button no longer removes it). Mirrors
* `itemDisabledClass`; a stable hook for greying the inert chip.
*/
tagDisabledClass: string;
/**
* Class added to `rootEl` while the popup is open. Use it as a CSS hook
* for open-state styling (also available as `[data-state='open']` on the
* trigger).
*/
openClass: string;
/** DOM `id` of `triggerEl`. Unique across instances. */
triggerId: string;
/**
* DOM `id` minted onto the `labelEl` setting's element when it has none
* (the resolved `ariaLabelledBy` then references it). Unique across
* instances. Unused when `labelEl` is null or already carries an id.
*/
labelId: string;
/** DOM `id` of the trigger content span. Unique across instances. */
triggerContentId: string;
/**
* DOM `id` of the hidden plain-text value span (root-level sibling of the
* trigger). Unique across instances. Referenced by the filterable-mode
* trigger's `aria-labelledby` chain so the closed button's accessible name
* includes the current value as PLAIN TEXT - rich trigger content (tag
* chips with labelled remove buttons) must not leak control names into the
* field name (see the `ariaLabel` / `ariaLabelledBy` settings).
*/
triggerValueId: string;
/**
* DOM `id` of `popupListEl` (the inner listbox). Unique across instances.
* Referenced by the trigger's `aria-controls` attribute.
*/
popupListId: string;
/** Class on the filter input element inside the popup. */
filterInputClass: string;
/** DOM `id` of the filter input. Unique across instances. */
filterInputId: string;
}
/**
* Package-internal (not re-exported): subclasses detect it to pick fast paths.
* SameValueZero (`===` plus `NaN` equals `NaN`), matching the `Set` those fast
* paths use - `===` alone let a `NaN` item be chosen twice (QUALITY-92).
* Second job: the reference-swap check in both `onItemsChanged` ("same value?",
* a different question from item identity). Keep it SameValueZero for that too.
*/
export declare function defaultCompareFn<T>(a: T, b: T): boolean;
/** Package-internal test hook (not re-exported): reset the once-per-page unnamed-name warning. */
export declare function resetUnnamedNameWarning(): void;
/** Package-internal test hook (not re-exported): reset the once-per-page duplicate-items warning. */
export declare function resetDuplicateItemsWarning(): void;
/**
* Abstract base for all select variants. Owns DOM scaffolding, ARIA wiring,
* positioning, keyboard navigation, lazy popup-list rendering, and
* outside-click handling. Subclasses (`LLSelectSingle`, `LLSelectMultiple`)
* own chosen-state, decide what happens on item click, and customise the
* trigger text via `renderTriggerContent`.
*
* @typeParam T - item value type. Use `unknown` (default) only when you
* intend to narrow inside templates / handlers; usually pass a concrete
* type like `string` or your domain object.
* @typeParam GroupKey - the group key type `itemToGroupKeyFn` returns
* (`null` from that fn means "this item is in no group"). Defaults to
* `string`. Open it to objects only together with `groupKeyCompareFn`; see
* DESIGN.md "Data model".
* @typeParam S - the resolved settings type, for subclasses that EXTEND the
* settings bag. Plain use never passes it. A subclass declares
* `extends LLSelectBase<T, GroupKey, MySettings>` and `this.settings` is
* typed `MySettings`; the constructor's `subclassSettings` param then only
* accepts exactly the extra fields.
* @group Select classes
*/
export declare abstract class LLSelectBase<T = unknown, GroupKey = string, S extends LLSelectBaseSettings<T, GroupKey> = LLSelectBaseSettings<T, GroupKey>> {
/**
* The caller-passed mount element, now decorated as the select's root.
* Library does not replace this node, so the caller's original reference,
* id, and data-* attributes stay valid.
* @group DOM elements
*/
readonly rootEl: HTMLElement;
/**
* The interactive trigger element. Receives focus, click, and keydown
* events; carries `aria-expanded`, `aria-controls`, and
* `data-state="open|closed"`. Its `role` depends on the filter mode: `combobox`
* while the filter is inactive (it then also hosts `aria-activedescendant`) and
* `button` while a filterable popup is open (the filter input hosts
* `aria-activedescendant`). See `docs/llm/A11Y.md`.
* @group DOM elements
*/
readonly triggerEl: HTMLElement;
/**
* Inner span inside the trigger where text/tags are written.
* Subclasses' `renderTriggerContent` writes here so the sibling arrow slot
* is preserved across re-renders.
* @group DOM elements
*/
readonly triggerContentEl: HTMLElement;
/**
* Hidden root-level span (sibling of the trigger) mirroring the current
* value as plain text. Kept in sync by `commitTriggerContentToDom`;
* referenced by the filterable-mode `aria-labelledby` chain (see
* `classIdMap.triggerValueId`). Outside the trigger so
* `triggerEl.textContent` stays exactly the visible content.
* @group DOM elements
*/
protected readonly triggerValueEl: HTMLElement;
/**
* The outer popup wrapper. Has no ARIA role itself - it just hosts the
* popup chrome (future: filter input, toggle-all control) and the inner
* `popupListEl`. Hidden via the `hidden` attribute when closed; positioned
* via inline styles by the positioner when open.
* @group DOM elements
*/
readonly popupEl: HTMLElement;
/**
* The inner element with `role="listbox"`, holding the item children.
* Sits inside `popupEl` so siblings (filter input, toggle-all) can live
* above it without violating ARIA's "listbox children must be options"
* rule. The trigger's `aria-controls` points to this element.
* @group DOM elements
*/
readonly popupListEl: HTMLElement;
/**
* Resolved class names and ids for this instance.
* @group DOM elements
*/
readonly classIdMap: LLSelectClassIdMap;
/**
* Resolved settings (defaults applied) - ONE bag for the whole hierarchy.
* Typed by the class's `S` param: a subclass that extends the settings
* passes its resolved extra fields through the constructor's
* `subclassSettings` param, and this field is `S` with no re-typing
* (see `LLSelectSingle` / `LLSelectMultiple`).
* @group State (protected)
*/
protected readonly settings: S;
/** Raw explicit `placeholder` (constructor or `setPlaceholder`); `setUiTranslationPack` re-resolves against it. */
private explicitPlaceholder;
/** True when the constructor minted `classIdMap.labelId` onto `labelEl`; `destroy()` then removes it. */
private labelElIdMinted;
/** Click handler bound to `labelEl`: focus the trigger, never open (native label parity). */
private readonly handleLabelElClick;
/**
* Current item list. Defensive copy of what `setItems` was given.
* @group State (protected)
*/
protected items: T[];
/** Whether the popup is currently open; public reader {@link isOpened}. */
private opened;
/**
* Index (into `items`) of the currently keyboard-focused item, or `-1`
* when nothing is focused (closed popup, or no items).
* @group State (protected)
*/
protected focusedIndex: number;
/** Control-level disabled state (whole select); toggled via `setDisabled`. */
private disabled;
/**
* Who the change being applied right now is attributed to; the variants'
* `onChange` firing reads it. Set to `'user'` by `withUserChangeSource`
* and consumed (reset to `'api'`) by the first `onChange` fired, so a
* nested api-driven change inside an `onChange` handler reports `'api'`.
* @group State (protected)
*/
protected changeSource: LLSelectChangeSource;
private triggerArrowEl;
/** The clear button once a trigger render has built it (`clearable` only), else `null`; every later trigger render swaps it through `createTriggerClearButtonEl` (or keeps it, if that override returns the same element). */
private triggerClearButtonEl;
private positioner;
/**
* Whether the Popover API exists (feature-detected once per instance).
* When true the popup renders in the top layer while open - above every
* stacking context, immune to containing-block-creating ancestors - while
* staying in place in the DOM. See `docs/llm/DESIGN.md` "In-place popup".
*/
private readonly popoverSupported;
private itemEls;
private focusedEl;
/**
* The leading row element (`LLSelectMultiple`'s choose-all) for the current
* render, or `undefined` when absent. Never part of `itemEls`.
*/
private leadingRowEl;
/** Whether keyboard focus sits on the leading row (mutually exclusive with an item focus). */
private leadingRowFocused;
private outsideHandler;
private focusOutHandler;
/**
* Block-mode only. Suppresses the browser's default focus shift on outside
* mousedown so the focusout-close path cannot race ahead of the click
* capture and detach it before the click fires.
*/
private blockMouseDownHandler;
/**
* Element that owns `aria-activedescendant` and receives keydown for option
* navigation. Equals the filter input while the filter is active, else the
* trigger. Re-pointed by `syncFilterModeToDom` (constructor + every open).
*/
private comboboxEl;
/**
* Filter input element. Always built into the popup DOM and always wired
* (a `hidden` input receives no events); kept `hidden` while the filter is
* inactive.
*/
private filterInputEl;
/**
* No-results message element (`role="status"`). Always built (like the
* filter input), sits AFTER the listbox inside `popupEl` so the listbox
* keeps its options-only children contract; `hidden` while the visible
* list has entries.
*/
private popupListNoResultsEl;
/**
* Text last written into the no-results region, or `null` while it is hidden.
* Guards a re-announcement: `role="status"` speaks on every content change, so
* a keystroke that keeps the list empty must not rewrite identical text (A11Y.md:
* announced once per appearance). Reset to `null` when the region hides, so the
* next appearance announces again.
*/
private lastNoResultsText;
/**
* Whether the filter input is active for the CURRENT open cycle. Evaluated
* from the `filterable` setting (predicate form reads the current items) in
* the constructor and on every `open()` - never re-evaluated mid-open, so
* the focus host cannot be yanked while the popup is up.
*/
private filterActive;
private query;
private filteredItems;
private typeaheadBuffer;
private typeaheadLastTime;
/** Memoized display order of `items` (`gatherGroups` gather); `undefined` = recompute on next need. */
private gatheredItems;
private composing;
/**
* @param targetEl - mount element. Becomes `rootEl`; its existing children
* are wiped and replaced with the trigger + popup structure. Pre-set
* classes / id / data-* attributes on this element are preserved.
* @param settings - optional partial settings. Missing fields use defaults
* ({@link LLSelectBaseSettings}).
* @param subclassSettings - for subclasses that EXTEND the settings bag: their
* own fields, already resolved (defaults applied). Typed by the class's
* `S` param, so it accepts exactly the extra fields and nothing else.
* Merged into `this.settings` right here, so the bag is complete before
* any base construction code (e.g. `createFilterInputEl` reading the
* pack) can read it.
* @group Lifecycle
*/
constructor(targetEl: HTMLElement, settings?: LLSelectSettingsInputOf<S>, subclassSettings?: Omit<S, keyof LLSelectBaseSettings<T, GroupKey>>);
/**
* Evaluate the `filterable` setting against the current items: booleans
* pass through, the predicate form is called with the full item list.
*/
private computeFilterActive;
/**
* Mirror `filterActive` onto the DOM + wiring it decides: the trigger's
* role (`button` while active, `combobox` while not), the filter input's
* `hidden` flag, and which element `comboboxEl` points at (the
* `aria-activedescendant` / focus host). Called from the constructor and
* from `open()` after re-evaluation.
*/
private syncFilterModeToDom;
/**
* Reflect the field's accessible name (`ariaLabel` / `ariaLabelledBy`) onto
* the elements that carry it. Runs with `syncFilterModeToDom` (constructor +
* every open) because the trigger's wiring depends on the mode:
* - Trigger, filter inactive (`role="combobox"`): the name directly; the
* combobox VALUE already comes from the trigger content.
* - Trigger, filter active (`role="button"`): a button's name would
* otherwise be its content (the current value) with no field name, so
* `aria-labelledby` chains label + content span. With only `ariaLabel`
* there is no label element to reference, so the chain starts at the
* trigger itself - the accname algorithm substitutes its `aria-label`.
* - Filter input: the field name replaces the `uiTranslationPack.filterInputAriaLabel`
* fallback (while the filter is active the input IS the field's combobox).
* - Listbox: the field name, both modes.
*/
private syncFieldNameToDom;
/**
* Open the popup. Builds item elements lazily, attaches the positioner
* (which auto-closes if the trigger is scrolled out of view), wires the
* outside-click handler, and moves keyboard focus into the item list.
* No-op if already open.
* @group Open & close
*/
open(): void;
/**
* Close the popup. Detaches positioner and outside-click listener, clears
* the item DOM, and resets focused-item state. No-op if already closed.
*
* Focus return is decided automatically: when `filterable: true` and DOM
* focus is still on the filter input at the moment of close (Esc on empty
* filter, single-select pick, click on non-focusable area outside), focus
* is returned to the trigger. Tab-away and outside clicks on focusable
* elements have already moved focus elsewhere, so we leave it alone.
* @group Open & close
*/
close(): void;
/**
* Rebuild the trigger and (while open) the popup list from current state.
* - Use it after mutating item OBJECTS in place (e.g.
* `users[0].name = 'X'`). The library cannot detect that on its own.
* - It re-derives the display order (the `gatherGroups` gather).
* - While the filter is active, it re-runs the filter against the current
* item text.
* - The refresh is purely visual: it does NOT fire `onChange` and does NOT
* run `onItemsChanged`.
* - Orchestrator: composes `renderTrigger` + `renderPopupList`; touches no
* DOM directly.
* @group Lifecycle
*/
rerender(): void;
/**
* Tear down the instance: close the popup (which detaches every document /
* window listener and the positioner), unwire `labelEl` (click listener
* removed, a minted id removed), remove the library's class and
* inline styles from the caller's mount element, and empty it. Idempotent.
* The instance must not be used afterwards.
* - REQUIRED before discarding an instance that might be OPEN (framework
* wrappers: call this on unmount) - skipping it there leaks the
* outside-click / focusout / scroll / resize listeners.
* - Discarding a CLOSED instance without `destroy()` leaks nothing; it only
* leaves the root class and `overflow-anchor` style on the mount.
* @group Lifecycle
*/
destroy(): void;
/**
* Open if closed, close if open.
* @group Open & close
*/
toggle(): void;
/**
* Whether the popup is currently open.
* - Pairs with `isDisabled()` (state read via method).
* - The same state is mirrored on the DOM as CSS hooks:
* `data-state="open|closed"` on the trigger, `classIdMap.openClass` on
* the root.
* @group Open & close
*/
isOpened(): boolean;
/**
* Return the current item list, in data order (as passed to `setItems`).
* - The rendered list may differ in order and content: see `getVisibleItems`.
* - Returns the LIVE internal array, typed read-only. Do not mutate it
* (TS blocks it; plain-JS callers must treat it as frozen).
* - Structural mutation would silently bypass chosen-state reconciliation,
* re-filtering, and re-render. Replace the list via `setItems` instead.
* - Mutating item OBJECTS + `rerender()` is the supported in-place path.
* @group Items
*/
getItems(): readonly T[];
/**
* Return the resolved UI strings: the built-in English defaults merged
* with the `uiTranslationPack` setting.
* - Reuse these in your own UI instead of keeping a second translation
* source. For example, a tag remove button tooltip:
* `sel.getUiTranslationPack().tagRemoveButtonAriaLabel(label)`.
* - Returns the LIVE object. Treat it as immutable, like `getItems`.
* @group i18n
*/
getUiTranslationPack(): Readonly<LLSelectUiTranslationPack>;
/**
* Replace the UI-translation pack at runtime, so switching language needs
* no re-`new`.
* - One of the two settings with a runtime setter (the other is
* `setPlaceholder`); both are copy. The rule: {@link LLSelectBaseSettings}.
* - The pack is resolved exactly like the constructor's: merged over the
* built-in English pack, NOT over the previously set pack.
* - An explicit constructor `placeholder` keeps winning over the new pack's
* `triggerPlaceholder`.
* - Re-renders the trigger and the open popup.
* - Also re-applies the pack-owned attributes `rerender()` cannot reach:
* the filter input placeholder and its fallback `aria-label`.
* - The clear button needs no such step: `rerender()` rebuilds it, and the
* rebuild reads the new pack - unless a `createTriggerClearButtonEl`
* override returns the previous element, which then owns the label.
* @group i18n
*/
setUiTranslationPack(uiTranslationPack: Partial<LLSelectUiTranslationPack>): void;
/**
* Replace the trigger placeholder text at runtime. It is one of the two
* settings with a runtime setter (the other is `setUiTranslationPack`);
* both are copy. The rule: {@link LLSelectBaseSettings}.
* - `null` = fall back to the pack default (`uiTranslationPack.triggerPlaceholder`),
* mirroring an unset constructor `placeholder`. An explicit value keeps
* winning over later `setUiTranslationPack` calls, exactly like the
* constructor input.
* - Takes effect immediately. Visible only while nothing is chosen - the
* placeholder never renders otherwise (the trigger is still re-rendered,
* which also refreshes the hidden accessible-value mirror).
* @group Trigger
*/
setPlaceholder(placeholder: string | null): void;
/**
* Replace the item list.
* - The input is shallow-copied; later external mutation does not affect
* the select.
* - Items MUST be unique under `compareFn` (it defines item identity, and the
* selection is a set). Duplicates render stale selection DOM; the default
* compareFn warns once per page, a custom compareFn is the caller's
* responsibility (not scanned, to keep large lists cheap).
* - If the popup is open, it re-renders now. While closed, the DOM is
* built lazily on the next `open()`.
* - Both shipped variants re-render the trigger content from `onItemsChanged`
* (the multiple count total, custom content that reads `items`).
* - `LLSelectMultiple`'s `triggerDisplay: 'tags'` mode is opt-in; `'count'` is
* the default.
* - When `triggerDisplay` is `'tags'`, that content render is one chip per
* chosen item, unless `createTriggerContentElFn` replaces the content.
* - Subclasses may reconcile chosen-state via {@link onItemsChanged}
* (e.g. single mode drops a chosen value that is no longer in the list).
* @group Items
*/
setItems(items: readonly T[]): void;
/**
* Warn (once per page, never throw) when the item list has duplicates under
* the DEFAULT compareFn - an O(n) Set check. A custom compareFn is documented
* only: an O(n^2) scan would tax large lists (see PERFORMANCE-31), and keeping
* its identity unique is the caller's responsibility.
* - The Set is SameValueZero, and so is the default compareFn, so a repeated
* `NaN` item counts as a duplicate on both sides.
*/
private warnOnDuplicateItems;
/**
* Enable or disable the whole control. Disabled: the trigger gets
* `aria-disabled` + `data-disabled` (never the native `disabled` attribute,
* which would suppress the hover / focus events a tooltip needs), opening is
* blocked, an open popup closes, and the trigger leaves the tab order unless
* `focusableWhenDisabled` is set. Stored as state, mirroring `setItems` /
* `setChosenItems` (this design keeps mutable state out of settings).
* @group Disabling
*/
setDisabled(value: boolean): void;
/**
* Whether the whole control is disabled.
* @group Disabling
*/
isDisabled(): boolean;
/** Reflect `this.disabled` onto the trigger's ARIA / data / tabindex. */
private syncDisabledStateToDom;
/**
* Recompute the trigger's tabindex from every input that owns it: disabled
* state (with `focusableWhenDisabled`), and the filterable open cycle -
* while the filter input is the focus host the trigger leaves the tab
* order, so the open widget stays a single tab stop and Shift+Tab exits
* instead of landing on the trigger with the popup still open
* (`docs/llm/A11Y.md` "Focus").
*/
private syncTriggerTabindex;
/**
* Subclass hook: called once after the popup finishes opening. Default no-op.
* The `onOpen` setting fires alongside this (both run) - hook for subclass
* logic, setting for consumer notification.
* @group Subclassing: reactions
*/
protected onOpened(): void;
/**
* Subclass hook: called once after the popup finishes closing. Pairs with the `onClose` setting (both run).
* @group Subclassing: reactions
*/
protected onClosed(): void;
/**
* Subclass hook: called after the chosen state actually changed, right
* before the variant's `onChange` setting fires (hook first, both run -
* same pairing as `onOpened` / `onClosed`). Default no-op.
* @group Subclassing: reactions
*/
protected onChosenChanged(): void;
/**
* Called after `setItems` finishes. Override to reconcile state that
* depends on the item list (e.g. clear a chosen value that disappeared).
* Default no-op.
* @group Subclassing: reactions
*/
protected onItemsChanged(): void;
/**
* Orchestrator: composes the clear-button rebuild + `renderTriggerContent` +
* `renderTriggerArrow` to (re)build the whole trigger from state; touches no
* DOM directly. Subclasses normally override {@link renderTriggerContent},
* not this.
* - Runs on every change of the chosen value, the placeholder or the pack.
* - `rerender()` runs it too.
* - While `clearable` is on, the first run builds the clear button and every
* later run swaps it for whatever `createTriggerClearButtonEl` returns: a
* fresh button from the base implementation, like the arrow. An override
* may return the previous element; it is then kept in place.
* - If the button is rebuilt and the old one held focus - on it or inside
* its icon - the rebuilt BUTTON gets it.
* - If the builder returned the same element, nothing was rebuilt, and focus
* goes back to the node that held it, if that node is still inside the
* button and can take focus; otherwise focus stays on the button.
* - `setItems` runs `renderTriggerContent` alone, because only the content
* reads the list (the multiple count total, a custom
* `createTriggerContentElFn`'s `items`).
* - A `setItems` that drops the chosen entry runs the whole trigger.
* - Runs once from the `LLSelectSingle` / `LLSelectMultiple` constructor, right
* after `super()`.
* - On that first run a FURTHER subclass's own fields are still `undefined`:
* JS runs a subclass's field initializers only after its super constructor
* returns (virtual-call-in-constructor). An override of a trigger method
* (`renderTriggerContent`, or a `create*El` it calls) that reads such a
* field sees `undefined` there.
* - The recipe: put construction-time configuration in the typed
* `subclassSettings` constructor param. `this.settings` is complete before
* any construction code runs.
* - For genuine instance state, tolerate defaults during construction, or call
* `rerender()` at the end of your own constructor.
* - The popup-list methods do NOT run here; they wait for `open()`. See
* DESIGN.md "Customization model".
* @group Subclassing: rendering
*/
protected renderTrigger(): void;
/**
* Write the trigger's content slot (`triggerContentEl`), replacing whatever
* was there; the sibling arrow slot is untouched. The single DOM-writing
* primitive behind every `renderTriggerContent` path.
* - `string` -> set as `textContent` (plain text, NOT parsed as HTML). Used
* for the default placeholder / `itemToString` text / count summary.
* - `HTMLElement` -> inserted as-is via `replaceChildren`; caller owns the
* node. Used for whatever the `createTriggerContentElFn` setting returned.
* - Also mirrors the value into the hidden `triggerValueEl` (the accessible
* name source): the string itself, else `plainTextValue`, else the
* element's `textContent`. Pass `plainTextValue` whenever the element
* contains labelled controls (tag remove buttons) or icon-only content -
* the mirror is what AT announces as the field's value.
* Called by `renderTriggerContent` - the base default and the `LLSelectSingle`
* / `LLSelectMultiple` overrides.
* @group Subclassing: rendering
*/
protected commitTriggerContentToDom(content: HTMLElement | string, plainTextValue?: string): void;
/**
* Mirror the empty/filled state onto the trigger's `data-empty` attribute
* (`"true"` when `isEmpty()`, else `"false"`). A CSS / AT styling hook,
* independent of the rendered content. Called by the subclass
* `renderTriggerContent` overrides.
* @group Subclassing: rendering
*/
protected syncEmptyStateToDom(): void;
/**
* Whether the control currently has no selection (drives `data-empty`).
* Base default is always `true` (the base trigger only shows the
* placeholder); `LLSelectSingle` / `LLSelectMultiple` override it.
* @group Subclassing: semantics
*/
protected isEmpty(): boolean;
/**
* Orchestrator: composes the `*ToDom` primitives to (re)build the trigger's
* content slot from state; touches no DOM directly. Override in subclasses to
* display the chosen value(s); this base default commits the placeholder and
* the empty flag. Always write via `commitTriggerContentToDom` (content) and
* `syncEmptyStateToDom` (the `data-empty` flag), never `triggerContentEl`
* directly, so the sibling arrow slot is always preserved.
* @group Subclassing: rendering
*/
protected renderTriggerContent(): void;
/**
* Orchestrator: composes the `*ToDom` / `*El` primitives to (re)build the
* trigger's arrow slot from state; touches no DOM directly. Calls
* `createTriggerArrowContentEl` with the current `isOpened` and commits whatever it returns
* (including `null` -> no arrow for this state).
*/
private renderTriggerArrow;
/**
* Trigger arrow element for the given open state.
* - Default reads `createTriggerArrowContentElFn`; `null` (setting unset, or returned for
* a state) = no arrow for that state.
* - Override only when extending; for one-off arrows pass the setting.
* Mirrors `createTriggerClearButtonEl` / `createItemContentEl`.
* @group Subclassing: rendering
*/
protected createTriggerArrowContentEl(state: {
isOpened: boolean;
}): HTMLElement | SVGElement | null;
/**
* Write the trigger's arrow slot: clear it, then append `el` if non-null.
* - `el = null`: clear only, leaving the slot empty (no arrow this state).
* The sole mutator of the arrow slot; called by `renderTriggerArrow`.
*/
private commitTriggerArrowContentElToDom;
/**
* Orchestrator: composes `createItemEl` (build) + `computePopupSegments` +
* `commitPopupSegmentsToDom` (write) to rebuild the popup list from
* `getVisibleItems()`; touches no DOM directly. Called by `open()` and by
* `setItems()` while open. Also clamps `focusedIndex` if the list shrank and
* re-applies focus visuals.
* - Extend it by wrapping: override, do your work before or after, then
* call `super.renderPopupList()`. The ui-select bridge frees its row
* scopes this way. Or override one of the methods it calls through `this`:
* `createItemEl`, `createPopupListLeadingRowEl`, `itemToGroupKey`,
* `getVisibleItems`.
* - Its other internals stay private on purpose. They re-establish the
* `itemEls[i] <-> getVisibleItems()[i]` alignment as one unit, so no
* subclass can leave keyboard nav or `aria-activedescendant` half-synced.
* @group Subclassing: rendering
*/
protected renderPopupList(): void;
/**
* Split the flat visible list into render segments: ungrouped item elements
* and contiguous same-key groups. Pure computation - resolves keys via
* `itemToGroupKey` (the overridable method; all-`null` keys = flat list) and key
* equality via `groupKeyCompareFn`, touches no DOM. Group headers are NOT
* added to `itemEls`, so `itemEls[i]`
* stays aligned with `getVisibleItems()[i]` and keyboard nav skips headers for
* free. `console.warn`s once per non-contiguous key reappearance (unsorted
* data would otherwise emit a duplicate header for the same group) -
* reachable with `gatherGroups: false`; the default gather feeds this an
* already-contiguous list.
*/
private computePopupSegments;
/** Replace every popup-list child with the leading row (when present) + the rendered segments. */
private commitPopupSegmentsToDom;
/**
* Build a detached group container: `role="group"` named by `groupKeyToString`,
* an `aria-hidden` visible label element, then the group's item elements. The
* label content comes from `createGroupLabelContentEl` (rich header) when
* non-null, else the plain label text. `aria-disabled` + `data-disabled` when
* the group is disabled. Override for full control of the group element
* (mirrors `createItemEl`).
*
* @param key - the group's key
* @param index - group index in the current render; builds a stable id
* @param items - the group's items (for rich content / counts)
* @param itemEls - the group's already-built option elements
* @group Subclassing: rendering
*/
protected createGroupEl(key: GroupKey, index: number, items: readonly T[], itemEls: HTMLElement[]): HTMLElement;
/**
* Group header -> its visible content element (icon / count badge / rich
* markup). Mirrors `createItemContentEl`.
* - Default reads `createGroupLabelContentElFn`, else `null` so `createGroupEl`
* uses plain text from `groupKeyToString`.
* - The group's accessible name stays `groupKeyToString` (container `aria-label`);
* this fills only the visible, `aria-hidden` label content.
* - Override only when extending; for one-off rich headers pass the setting.
* @group Subclassing: rendering
*/
protected createGroupLabelContentEl(key: GroupKey, itemsInGroup: readonly T[]): HTMLElement | null;
/**
* Re-render a single item's element in place instead of rebuilding the
* whole popup list. The DOM work is O(1) regardless of list size, so
* flipping one selection in a 10k-item list does not recreate 10k nodes
* (the lookup to find the item is O(n), but that is a cheap comparison
* loop next to DOM mutation). No-op if the popup is closed or the item is
* not in the current list. Used by multi-select toggle.
* @group Subclassing: rendering
*/
protected replacePopupListItemElInDom(item: T): void;
/**
* Build the DOM element for one item. The base implementation sets `id`,
* `role="option"`, a click handler, and fills the visible content via
* {@link createItemContentEl} (which reads `createItemContentElFn`), falling
* back to `textContent` from {@link itemToString}. When the content is
* custom (non-null), the option's `aria-label` is set from `itemToString`
* so the accessible name stays the plain `itemToString` text. For one-off rich content
* (icons etc.) prefer the `createItemContentElFn` setting; override this only
* to control the whole element (tag, extra wiring).
*
* @param item - the item value
* @param index - index in `this.items`; used to build a stable id so
* `aria-activedescendant` can point to this element across re-renders.
* @group Subclassing: rendering
*/
protected createItemEl(item: T, index: number): HTMLElement;
/**
* Map an item to its display string. The library calls this everywhere it
* needs an item's text: list rows, the single trigger text, default filter.
* - Default reads the `itemToStringFn` setting, else `String(item)`.
* - Configure via `itemToStringFn` (no subclass needed).
* - Override only when extending (a new select type); your override replaces
* the default. For HTML content, subclass `createItemEl`.
* @group Subclassing: semantics
*/
protected itemToString(item: T): string;
/**
* Item -> the visible content of its list row (icon + text etc.).
* - Default reads `createItemContentElFn`, else `null` so `createItemEl` uses
* the plain-text default from `itemToString`.
* - Override only when extending; for one-off rich content pass the setting.
* @group Subclassing: rendering
*/
protected createItemContentEl(item: T): HTMLElement | null;
/**
* Whether `item` is effectively disabled - by `itemDisabledFn`, or because
* its group is disabled (`groupDisabledFn`). Group-disabled layers on top,
* so every item-disabled behavior (no selection, keyboard skip, aria)
* covers grouped items with no extra code. False when neither applies.
* The whole-control disabled state (`isDisabled()`) is a separate layer,
* not part of this answer.
* @group Subclassing: semantics
*/
protected isItemEffectivelyDisabled(item: T): boolean;
/**
* Map an item to its group key, or `null` when it belongs to no group.
* The authoritative method: rendering, the `gatherGroups` gather, and the
* disabled layer all resolve keys through this method, so an override
* drives them all - returning keys turns grouping on even with the setting
* unset (all-`null` keys = flat list). An override reading external state
* must call `rerender()` after that state changes (same contract as
* mutating item objects).
* - Default reads `itemToGroupKeyFn`, else `null` (grouping off).
* - Override only when extending; configure via the setting.
* @group Subclassing: semantics
*/
protected itemToGroupKey(item: T): GroupKey | null;
/**
* Map a group key to its header display text.
* - Default reads `groupKeyToStringFn`, else `String(key)`.
* @group Subclassing: semantics
*/
protected groupKeyToString(key: GroupKey): string;
/**
* Whether the whole group `key` is disabled per `groupDisabledFn` (false when unset).
* @group Subclassing: semantics
*/
protected isGroupDisabled(key: GroupKey): boolean;
/**
* First enabled index scanning from `start` (inclusive) by `step` (+1 / -1).
* Returns -1 if no enabled item lies in that direction. Used to skip disabled
* items during keyboard nav and initial focus.
* @group Subclassing: focus
*/
protected findNextEnabledIndex(start: number, step: number, list: readonly T[]): number;
/**
* Resolve a nav target index to the nearest enabled item. Arrows / Home / End
* stay put when no enabled item lies in the travel direction; Page falls back
* to the opposite direction so it lands as far as it can.
*/
private findEnabledIndexForAction;
/**
* Called when an item is activated (click or keyboard select). Default
* no-op; subclasses implement their selection behaviour (single mode picks
* and closes, multiple mode toggles and keeps the popup open).
* @group Subclassing: reactions
*/
protected onItemActivated(_item: T): void;
/**
* Optional non-item `role="option"` row pinned at the TOP of the listbox:
* inside the arrow-key ring (ArrowUp from the first item reaches it, Home
* lands on it, up-actions clamp there) but never inside `itemEls`, so the
* `itemEls[i] <-> getVisibleItems()[i]` alignment is untouched. Rebuilt on
* every `renderPopupList`. Base default: `null` = no leading row.
* `LLSelectMultiple` builds its choose-all row here (`chooseAllRow` setting).
* @group Subclassing: rendering
*/
protected createPopupListLeadingRowEl(): HTMLElement | null;
/**
* Run `fn` with chosen-state changes attributed to the user. The library
* wraps exactly its pointer / keyboard entry points with it - option
* activation, the tag remove button, the clear button, the choose-all row;
* everything else reports `'api'`.
* @group Subclassing: reactions
*/
protected withUserChangeSource<R>(fn: () => R): R;
/**
* Subclass hook: the leading row was activated - Enter while it is focused
* (subclasses also wire their row's click handler to this). Default no-op.
* @group Subclassing: reactions
*/
protected onLeadingRowActivated(): void;
/**
* Decide which item to focus when the popup opens. Default focuses the
* first item (or no-op if the list is empty). Override to focus the
* currently chosen item, last-used item, etc.
* @group Subclassing: focus
*/
protected focusInitial(): void;
/**
* Move keyboard focus to the item at `index`. The value is clamped to
* `[-1, items.length-1]`; pass `-1` to clear focus. Updates the focused
* class, `aria-activedescendant`, and scrolls the item into view. No-op
* if the clamped value equals the current focused index.
* @group Subclassing: focus
*/
protected setFocusedIndex(index: number): void;
/**
* Move keyboard focus onto the leading row. Returns whether the row is now
* focused (`false` = none is rendered, nothing changed). The item focus is
* cleared (`focusedIndex` becomes -1). Protected so a subclass can wire its
* leading row's click to focus-then-activate (mirroring how item clicks
* call `setFocusedIndex` before `onItemActivated`) and use it in
* `focusInitial` (the leading row is the listbox's FIRST option).
* @group Subclassing: focus
*/
protected focusLeadingRow(): boolean;
/**
* Rebuild the leading row in place (tri-state / text refresh) without
* touching the item elements - O(1) DOM work, mirroring
* `replacePopupListItemElInDom`. Falls back to a full `renderPopupList`
* when the row becomes inapplicable (builder returns `null`). No-op while
* closed or when no leading row is rendered.
* @group Subclassing: rendering
*/
protected replaceLeadingRowElInDom(): void;
/**
* Make the DOM reflect `focusedIndex`: move the focused class onto the focused
* item element, point `aria-activedescendant` at it, and scroll it into view;
* when `focusedIndex` is -1 or out of range, clear the class and the attribute.
* Reads `itemEls`, so it only has an effect while the popup is open (the list
* exists). Called after `focusedIndex` changes (`setFocusedIndex`) and after
* the list is rebuilt (`renderPopupList`).
*/
private syncFocusedIndexToDom;
/**
* Snapshot the window scroll position and return a function that restores
* it. Opening the popup must never move the page, but Firefox auto-scrolls
* the active option of a multiselectable listbox into view at the document
* level when the popup is shown - even though the popup is `position: fixed`.
* The returned restore runs synchronously and once more on the next frame,
* since that accessibility scroll can land after the current layout flush.
* No-op when nothing actually scrolled, so it never fights real user
* scrolling (and stays silent under jsdom, which has no `window.scrollTo`).
* Restores with `behavior: 'instant'`: the two-arg `scrollTo` obeys the
* page's CSS `scroll-behavior`, so under `scroll-behavior: smooth` the
* correction would render as a visible glide instead of a revert.
*/
private captureWindowScroll;
/**
* The element a pointer event actually hit. `ev.target` retargets to the shadow
* host when llselect is hosted inside an app's shadow root, so an inside click
* would read as outside and close the popup; `composedPath()[0]` pierces the
* boundary. With no shadow tree (llselect uses none itself) this equals `ev.target`.
* - Limitation: a CLOSED host shadow root truncates `composedPath()` at the root,
* so `[0]` is only the host and an inside click still reads as outside. A
* document-level listener cannot see into a closed root; hosting in an OPEN
* shadow root avoids it.
*/
private eventTargetNode;
private attachOutsideClick;
private detachOutsideClick;
/**
* Close the popup when keyboard focus leaves the widget entirely (e.g. Tab
* away). `focusout` bubbles, so listening on `rootEl` catches focus leaving
* any descendant; `relatedTarget` is the element gaining focus (or `null`).
* The check is written against `rootEl.contains` rather than "the trigger
* lost focus" so a future in-popup control - filter input, checkbox - keeps
* the popup open while it holds focus.
*/
private attachFocusOut;
private detachFocusOut;
private handleKeydown;
/**
* Native-`<select>`-style prefix typeahead for a printable-character
* keydown on the trigger.
* - Returns `true` when the event was consumed.
* - Runs only while the filter is inactive. With the popup open the filter
* input owns typing; while closed, a would-be-filterable open cycle
* leaves the keys alone (the `filterable` predicate is evaluated fresh -
* the cached `filterActive` can be stale between opens).
* - Space never joins the buffer; it stays the activate/open key (A11Y.md).
* - While closed: opens the popup first, then searches relative to
* {@link computeTypeaheadClosedStartIndex} - NOT to the convenience focus
* `focusInitial` parked, which would skip the first match.
* - Typing itself never changes the value and never fires `onChange`;
* activation stays Enter / Space / click.
* - Match rule, cycling, and wrap: {@link findTypeaheadIndex}. No match
* leaves the active option and the buffer as they are.
*/
private handleTypeaheadKeydown;
/**
* The option the closed-state typeahead treats as current, as an index into
* `list`, when the typed character is the keystroke that opens the popup.
* - The search starts AFTER this option: the opening keystroke is always a
* one-character buffer, because `close()` and a refused `open()` both
* empty the buffer.
* - Default `-1`: no current option, so the first match from the top wins.
* - The focus `focusInitial` parks on open is a convenience, not a
* selection - it must not shift this search.
* - Single mode overrides this with the chosen item's index, so a typed
* initial cycles past the current selection like a native `<select>`.
* - Search internals: `findTypeaheadIndex` in `keyboard.ts`.
* @group Subclassing: focus
*/
protected computeTypeaheadClosedStartIndex(_list: readonly T[]): number;
private createTriggerEl;
/**
* Build the clear (x) button for the `clearable` trigger slot.
* - The library owns the button, its click (stops propagation so it never
* toggles the popup, then `clearSelection`; a no-op while the control is
* disabled) and its `aria-label` (text from
* `uiTranslationPack.triggerClearButtonAriaLabel`).
* - `createTriggerClearButtonContentElFn` optionally fills the icon; else the
* theme's CSS glyph draws it.
* - The theme hides the button via `data-empty` while nothing is chosen.
* - It runs on every trigger render (`renderTrigger`).
* - The first run builds the button.
* - For `LLSelectSingle` / `LLSelectMultiple` and their subclasses, that
* first run is the variant constructor's render, right after `super()`,
* so it happens before a FURTHER subclass's field initializers.
* - A direct `LLSelectBase` subclass gets the button on its first trigger
* render (its own `renderTrigger()` call, or `rerender()` /
* `setPlaceholder`); until then the trigger is unrendered and there is no
* button.
* - Every later run (a value change, `setPlaceholder`,
* `setUiTranslationPack`, `rerender()`) swaps the button in place, unless
* this method returns the previous element - then it stays in place.
* - The base implementation returns a fresh element each run, so, like the
* arrow, nothing put on it from outside survives a render; customize it
* here.
* - An override that returns the same element every time owns everything
* the rebuild would otherwise refresh on it, including its `aria-label`
* after `setUiTranslationPack`.
* - An override that reads subclass fields calls `rerender()` at the end of
* its constructor, like every other trigger method.
* @group Subclassing: rendering
*/
protected createTriggerClearButtonEl(): HTMLElement;
/**
* The element holding DOM focus if that is `el` or a descendant, else `null`.
* - Read from `el`'s own root: inside a shadow root `document.activeElement`
* is the shadow HOST, so the answer must come from the `ShadowRoot`'s
* `activeElement` (the `Document`'s otherwise).
* - The result can sit inside an open shadow root under `el`, so
* `el.contains()` may reject it; use `containsComposed` for that check.
* - A CLOSED shadow root is opaque, so its host is returned.
*/
private focusedElementIn;
/** Whether `node` is `el` or inside it, walking up through open shadow hosts. */
private containsComposed;
/** Whether DOM focus is on `el` or inside it (see `focusedElementIn`). */
private isFocused;
/**
* Build the clear button on the first trigger render, and swap it for a fresh
* one on every later render, always through `createTriggerClearButtonEl` (the
* overridable builder) - so `rerender()` repairs an override that reads
* subclass fields. Keeps DOM focus on the new button when the old one held it.
* No-op without `clearable`.
* The order of the steps is load-bearing:
* 1. Read which node holds focus, if it is the old button or inside it.
* 2. If one does, park focus on the old button itself. A content fn that
* hands back the same icon element each time reparents that icon into
* the new button, and the reparenting must not move the focused node.
* 3. Build the new button.
* 4. Insert it.
* 5. If step 1 found focus, move focus to the new button - or, if that
* element cannot take focus, to the trigger.
* 6. Only then remove the old one.
* Why this order:
* - Removing the old button first drops DOM focus to `<body>` in every
* engine.
* - Where the engine also fires `focusout` on that removal, its
* `relatedTarget` is `null`. The open popup's focus-out guard reads that
* as focus leaving the widget.
* - Moving focus first makes the new button the `relatedTarget`, inside the
* root.
* A builder override that returns the SAME element every time is allowed:
* the element is kept in place, and focus goes back to the node step 1
* found, because nothing was rebuilt - unless the override detached that
* node or moved it out of the button, in which case focus stays on the
* button.
*/
private replaceTriggerClearButtonElInDom;
/**
* Clear button's visible content (its x icon).
* - Default reads `createTriggerClearButtonContentElFn`; `null` (setting
* unset, or returned) = no icon - the theme's CSS glyph draws the x.
* - Override only when extending; for one-off icons pass the setting.
* @group Subclassing: rendering
*/
protected createTriggerClearButtonContentEl(): HTMLElement | SVGElement | null;
/**
* Empty the selection (invoked by the clear button). Base is a no-op; single
* clears to `undefined`, multiple to `[]`. Goes through the normal setters, so
* `onChange` fires with the empty value. The wipe is total - chosen disabled
* items are cleared too (native `<select>` parity; `unchooseAll` is the
* enabled-only bulk op).
* @group Subclassing: semantics
*/
protected clearSelection(): void;
/**
* The filter input lives inside the popup, above the listbox. Always built
* (`hidden` when `filterable: false`) so a future runtime toggle is a CSS
* flip rather than a DOM rebuild. See `docs/llm/DESIGN.md`.
*/
private createFilterInputEl;
/**
* Return the items the popup list renders, in display order.
*
* ```text
* items (setItems)
* | gather (only with grouping on; result cached until setItems)
* v
* display base list
* | filter query (only while a query is active)
* v
* visible items (this method's return value)
* | render
* v
* DOM rows
* ```
*
* - If no filter query is active (including while closed), it returns the
* full list, gathered per `gatherGroups` when grouping is on.
* - If a filter query is active, it returns the matching subset.
* - Disabled items are included. They render (grayed); only actions skip
* them (keyboard focus, the choose-all row's subset).
* - Returns the LIVE internal array, typed read-only. Never mutate it
* (see `getItems`).
* - Subclasses use it too (e.g. for selection-by-index), and may override
* it to add a step: `LLSelectMultiple` does for `hideChosenRows`.
* @group Items
*/
getVisibleItems(): readonly T[];
/**
* The base list in DISPLAY order: `items` gathered per `gatherGroups`
* (memoized until the next `setItems`), or `items` as-is while grouping is
* off or `gatherGroups` is false. Filtering and rendering read this, never
* `items` directly - so the gather runs lazily, at first need after a
* `setItems`.
*/
private getDisplayBaseItems;
/**
* Return the filter input's current query, exactly the string passed to
* `filterFn`.
* - It is `''` while the popup is closed, the filter is inactive, or the
* input is empty.
* - It resets on close: each open cycle starts empty.
* @group Filtering
*/
getFilterQuery(): string;
/**
* Per-item match predicate for the filter input.
* - Default reads `filterFn`; else case-insensitive substring on
* `itemToString`.
* - Override only when extending (subclass-wide custom matching); for a
* one-off match rule pass the setting.
* @group Subclassing: semantics
*/
protected matchesQuery(item: T, query: string): boolean;
/**
* Recompute `filteredItems` from the current `items` and `query`. Pure state
* update: does NOT touch the DOM (the caller re-renders the list separately).
* No-op when `filterable: false`; an empty query keeps every item. Called from
* `open()`, from `setItems()`, and on each filter-input event.
*/
private recomputeFilteredItems;
/**
* Input event on the filter field: re-filter, re-render the list, move the
* active option to the first match. IME composition is guarded - we wait
* for `compositionend` and filter once with the composed text.
*/
private handleSearchInputEvent;
/** Outer popup wrapper. No ARIA role; structural only. */
private createPopupEl;
/**
* Build the no-results message element. `role="status"` announces its
* appearance politely; it lives OUTSIDE the listbox (options-only children)
* and its text comes from `uiTranslationPack.popupListNoResults`.
*/
private createPopupListNoResultsEl;
/**
* The no-results message's visible content (rich empty-state).
* - Default reads `createPopupListNoResultsContentElFn`; `null` (setting
* unset, or returned) = plain text from `uiTranslationPack.popupListNoResults`.
* - Override only when extending; for one-off content pass the setting.
* @group Subclassing: rendering
*/
protected createPopupListNoResultsContentEl(query: string): HTMLElement | null;
/**
* Mirror the visible-list-empty state onto the no-results message element:
* `hidden` while there is at least one visible item; when shown, fill its
* content - `createPopupListNoResultsContentEl(query)` first, else the plain
* text from `uiTranslationPack.popupListNoResults`. Same null-branch shape as
* `createItemEl` / `createGroupEl`.
* - The write is SKIPPED when the resolved text matches what is already shown,
* so a still-empty next keystroke does not re-announce (see `lastNoResultsText`).
*/
private syncPopupListNoResultsToDom;
/** Inner element with `role="listbox"`. Holds item children. */
private createPopupListEl;
}
//# sourceMappingURL=base.d.ts.map