@llselect/core
Version:
Low-level select: a minimal, flexible, framework-agnostic replacement for the native HTML select element.
462 lines • 22.9 kB
TypeScript
import { LLSelectBase, type LLSelectBaseSettings, type LLSelectChangeMeta, type LLSelectSettingsInputOf } from './base.js';
/**
* Trigger display mode of {@link LLSelectMultiple}.
* - `'count'`: a text summary like "3 / 10 selected".
* - `'tags'`: one removable chip per chosen item.
* See {@link LLSelectMultipleSettings.triggerDisplay}.
* @group Settings
* @category Multiple
*/
export type LLSelectTriggerDisplay = 'count' | 'tags';
/**
* Tri-state of the choose-all row (also the `data-chosen-state` attribute
* value): how much of the VISIBLE enabled subset is currently chosen.
* @group Settings
* @category Multiple
*/
export type LLSelectChosenState = 'none' | 'some' | 'all';
/**
* Context passed to {@link LLSelectMultipleSettings.createTriggerContentElFn}.
* @group Settings
* @category Multiple
*/
export interface LLSelectMultipleTriggerContext<T> {
chosenItems: readonly T[];
items: readonly T[];
}
/**
* Resolved (defaults applied) settings for {@link LLSelectMultiple}: the base
* settings plus the multi-mode fields - the runtime type of `this.settings`,
* one bag built complete in the constructor.
* @group Settings
* @category Multiple
*/
export interface LLSelectMultipleSettings<T, GroupKey = string> extends LLSelectBaseSettings<T, GroupKey> {
/**
* Fired when the chosen-items set actually changes. Receives the new set
* and the PREVIOUS one (the snapshot from before this change) - diff them
* with `compareFn` to compute added / removed. Does NOT fire on
* construction nor on a setter call that yields an equivalent set
* (element-wise compared via `compareFn`, order-sensitive).
* `null` (default) = no listener.
* - `meta.source` says who initiated the change: `'user'` for a pointer or
* keyboard interaction inside the widget (an option toggle, a tag's
* remove button, the clear button, the choose-all row), `'api'` for any
* programmatic call. See {@link LLSelectChangeMeta}.
* @group Events
*/
onChange: ((chosenItems: readonly T[], previousChosenItems: readonly T[], meta: LLSelectChangeMeta) => void) | null;
/**
* Render the trigger's content ELEMENT without subclassing - the setting
* equivalent of overriding `renderTriggerContent`. Receives the chosen items
* + items (same convention as `createItemContentElFn`):
* - `HTMLElement` - inserted into the trigger as-is; you own it. Use this for
* real markup such as tag chips.
* - fn returns `null` - use the default for this render (count summary / tags).
* - setting is `null` (default) - always use that default rendering.
* The DEFAULT `renderTriggerContent` checks it first; a subclass override
* replaces that default entirely and may ignore the setting - override
* wins, per DESIGN.md "Customization model".
* @group Trigger
*/
createTriggerContentElFn: ((ctx: LLSelectMultipleTriggerContext<T>) => HTMLElement | null) | null;
/**
* Trigger display mode.
* - `'count'` (default): a summary like "3 / 10 selected".
* - `'tags'`: one removable chip per chosen item; its x button removes it.
* `createTriggerContentElFn` overrides both (full control wins).
* @group Trigger
*/
triggerDisplay: LLSelectTriggerDisplay;
/**
* Item -> the visible content ELEMENT of its tag chip in `'tags'` mode,
* without subclassing. Mirrors `createItemContentElFn` (the chip is to the
* trigger what the option content is to the row):
* - Return an `HTMLElement` and the library inserts it as the chip's content;
* the library still owns the chip container + the remove (x) button + aria.
* - `null` (setting default, or returned for an item) = plain text from
* `itemToString`.
* - The remove button's accessible name comes from
* `itemToTagRemoveButtonAriaLabel` (default `Remove <itemToString>`) -
* that is what AT is guaranteed to announce. The chip is a generic `<span>` (ARIA prohibits naming it), so
* for icon-only content include your own (visually hidden) text if the
* chip should be announced as more than its remove button. See
* `docs/llm/A11Y.md` "Tags".
* @group Trigger
*/
createTagContentElFn: ((item: T) => HTMLElement | null) | null;
/**
* Icon ELEMENT of each tag's remove (x) button in `'tags'` mode, mirroring
* `createTriggerClearButtonContentElFn` (the clear button's icon hook). The library always owns the
* button, its click (removes the item + `stopPropagation`), `tabindex="-1"`, and
* the `aria-label` accessible name (from `itemToTagRemoveButtonAriaLabel`);
* this only fills the decorative icon.
* - Return an `HTMLElement` / `SVGElement`: appended inside the button as its icon.
* - `null` (setting default, or returned for an item): no icon - the theme
* draws the x via its CSS glyph (`.llselect-tag-remove-button:empty::before`).
* @group Trigger
*/
createTagRemoveButtonContentElFn: ((item: T) => HTMLElement | SVGElement | null) | null;
/**
* Hide the rows of chosen items from the popup list.
* - Default `false`: chosen rows stay listed and show their state.
* - While `true`, choosing an item removes its row at once and unchoosing
* puts it back. Tags, the trigger and `getChosenItems` are unaffected.
* - When every item is chosen, the popup shows the no-results element.
* - With `chooseAllRow`, the visible subset is always fully unchosen, so
* the row acts as "choose everything still listed", its tri-state never
* reaches all-chosen, and it disappears with the last actionable row.
* - Internals: a `getVisibleItems` subtraction. The default `compareFn`
* uses a Set lookup; a custom `compareFn` costs O(visible x chosen).
* Either cost is paid once per change of the list or the chosen set -
* the result is cached between changes. `toggleItem` swaps its O(1) row
* replace for a full rebuild.
* @group Items
*/
hideChosenRows: boolean;
/**
* Whether the popup shows a choose-all row (the industry's "select all")
* as the first option of the listbox.
* - Default `false`.
* - Activating the row (Enter / click) runs `toggleAllVisible`: it toggles
* the visible enabled subset (the matching subset while a filter query
* is active). The public `chooseAll` / `unchooseAll` / `toggleAll` keep
* their whole-list semantics.
* - The row is tri-state (none / some / all chosen), carried by the
* counting text's numbers and the `data-chosen-state` CSS hook.
* - Its accessible name comes from `uiTranslationPack.chooseAllRowText`.
* - See `docs/llm/A11Y.md` "Choose-all".
* @group Choose-all
*/
chooseAllRow: boolean;
/**
* The choose-all row's visible content ELEMENT, without subclassing - e.g.
* a tri-state SVG checkbox (`createOutlinedCheckboxSvgEl`) + the counting text. Mirrors
* `createItemContentElFn`. Only used with `chooseAllRow: true`.
* - Receives the tri-state and the counts of the visible enabled subset.
* - Return an `HTMLElement`: inserted as the row's content; the accessible
* name stays pinned to `uiTranslationPack.chooseAllRowText` via `aria-label`, so
* icon-only content is still announced with the counts.
* - `null` (setting default, or returned): the default content - just the
* plain counting text; its numbers carry the tri-state. The library
* ships no default indicator (consistent with items and the arrow);
* passing this setting is how one (e.g. `createOutlinedCheckboxSvgEl`)
* gets added. See DESIGN.md "Choose-all default: plain counting text".
* @group Choose-all
*/
createChooseAllRowContentElFn: ((chosenState: LLSelectChosenState, chosenCount: number, totalCount: number) => HTMLElement | null) | null;
}
/**
* Constructor-time settings input for {@link LLSelectMultiple}.
* Every field is optional; missing fields use defaults.
* @group Settings
* @category Multiple
*/
export type LLSelectMultipleSettingsInput<T, GroupKey = string> = LLSelectSettingsInputOf<LLSelectMultipleSettings<T, GroupKey>>;
/**
* Multi-selection select. Clicking an item toggles its membership in the
* chosen-items set and keeps the popup open. Each item DOM gets
* `aria-selected="true|false"`; the popup list gets
* `aria-multiselectable="true"`.
*
* Default trigger display is a count summary ("3 / 10 selected" / "All N
* selected" / placeholder when empty). Pass `createTriggerContentElFn` (or
* subclass `renderTriggerContent`) to customise (e.g. tag chips).
*
* @typeParam T - item type.
* @typeParam GroupKey - group key type of `itemToGroupKeyFn`; see
* {@link LLSelectBase}.
* @typeParam S - resolved settings type, for subclasses extending the
* settings bag; see {@link LLSelectBase}.
* @group Select classes
*/
export declare class LLSelectMultiple<T = unknown, GroupKey = string, S extends LLSelectMultipleSettings<T, GroupKey> = LLSelectMultipleSettings<T, GroupKey>> extends LLSelectBase<T, GroupKey, S> {
/**
* Currently chosen items, in insertion order.
* @group State (protected)
*/
protected chosenItems: readonly T[];
/**
* Build the control inside `targetEl`.
* - Settings are resolved once here; missing fields get defaults.
* - They are frozen afterwards, except `placeholder` and `uiTranslationPack`,
* which have runtime setters; the rule is at {@link LLSelectBaseSettings}.
* - This plain form infers `T` from a typed callback in `settings` whose
* signature contains `T` (`itemToStringFn: (u: User) => ...`). With no
* such callback, pass `T` explicitly: `new LLSelectMultiple<string>(...)`.
* @group Lifecycle
*/
constructor(targetEl: HTMLElement, settings?: LLSelectMultipleSettingsInput<T, GroupKey>);
/**
* Subclass form. `subclassSettings` is the typed pass-through for subclasses
* that extend the settings bag further; see `LLSelectBase`'s `S` param.
* @group Lifecycle
*/
constructor(targetEl: HTMLElement, settings?: LLSelectSettingsInputOf<S>, subclassSettings?: Omit<S, keyof LLSelectMultipleSettings<T, GroupKey>>);
/**
* Return the currently chosen items (insertion order).
* @group Selection
*/
getChosenItems(): readonly T[];
/**
* Replace the entire chosen-items list.
* - The input is copied, and duplicates (per `compareFn`) collapse to
* their first occurrence: the chosen items are a set.
* - Fires `onChange` only when the new list differs from the current one.
* The comparison is order-sensitive: chosen order is visible state
* (tags render in it).
* - It ignores disabled state: it can add and drop disabled items, unlike
* the `choose*` bulk ops. Assigning to a native `<select>` behaves the
* same.
* @group Selection
*/
setChosenItems(items: readonly T[]): void;
/**
* Whether the given item is currently chosen (via `compareFn`).
* @group Selection
*/
isChosen(item: T): boolean;
private chosenSetCache;
/** Memoized Set of `chosenItems` for the default-compareFn `isChosen` fast path. */
private chosenSet;
/**
* Toggle the membership of `item` in the chosen-items set. Adds at the end
* if not present; removes if present. Fires `onChange`.
* - While `hideChosenRows` is on, the popup list is rebuilt so the row
* leaves or re-enters it.
* @group Selection
*/
toggleItem(item: T): void;
/**
* Choose every enabled item.
* - It acts on enabled items only, like every `choose*` bulk op. Bulk ops
* mirror clicking, and clicking cannot reach disabled items.
* - Already-chosen disabled items are preserved. To change disabled items
* too, use `setChosenItems`.
* - Fires `onChange` only when the chosen items actually change.
* @group Selection
*/
chooseAll(): void;
/**
* Unchoose every enabled item.
* - Already-chosen disabled items are preserved. Bulk ops mirror clicking,
* and clicking cannot reach disabled items.
* - Two paths DO drop them: the clear button, and `setChosenItems([])`.
* - Fires `onChange` only when the chosen items actually change.
* @group Selection
*/
unchooseAll(): void;
/**
* Toggle between "all enabled chosen" and "none chosen".
* - It ignores disabled items, like every `choose*` bulk op.
* - This is NOT the in-popup choose-all row's action. The row acts on the
* visible enabled subset only: see {@link toggleAllVisible}.
* @group Selection
*/
toggleAll(): void;
/**
* Toggle the visible enabled items between all-chosen and all-unchosen.
* - This is the choose-all row's action (the `chooseAllRow` setting) as a
* public method. The row delegates here.
* - Acts on exactly the items that satisfy all of the following:
* - Visible: the item matches the active filter query. If no query is
* active, every item is visible. This is the same list as
* `getVisibleItems`.
* - Enabled: not disabled via `itemDisabledFn`, and not in a disabled
* group.
* - If all of them are already chosen, it unchooses exactly those.
* - Otherwise, it chooses the ones still missing.
* - Choices outside that set (filtered-out or disabled) are preserved
* either way.
* - If no filter query is active, the acted-on set is every enabled item,
* the same scope as `toggleAll`.
* - Fires `onChange` only when the chosen items actually change.
* - The acted-on set is computed by the overridable method `getVisibleEnabledItems`,
* shared with the choose-all row.
* @group Selection
*/
toggleAllVisible(): void;
/**
* Return the visible enabled subset: the items `toggleAllVisible` and the
* choose-all row act on.
* - It is `getVisibleItems()` minus the effectively disabled items
* (`itemDisabledFn`, disabled groups).
* - Both the choose-all row (its counts, tri-state, and click) and
* `toggleAllVisible` read this one method, so an override keeps them in
* agreement. Example: the tree-select demo subclass narrows it to leaf
* nodes.
* @group Subclassing: semantics
*/
protected getVisibleEnabledItems(): readonly T[];
/**
* hideChosenRows subtraction cache. Every layer above (items, gather,
* filter) and the chosen set REPLACE their arrays on change, never mutate
* in place - so two reference checks are a complete validity test and no
* invalidation wiring is needed.
*/
private visibleItemsCache;
/**
* Return the items the popup list renders, in display order.
*
* ```text
* base visible items (LLSelectBase.getVisibleItems: gather + filter)
* | minus chosen (only while hideChosenRows is on; result cached)
* v
* visible items (this method's return value)
* ```
*
* - Identical to the base behavior, minus the chosen items while
* `hideChosenRows` is on.
* - While `hideChosenRows` is on and something is chosen, it returns a
* cached fresh array, not the live internal one.
* - The subtraction recomputes only when the base list or the chosen set
* changed. Calls in between return the same cached array.
* @group Items
*/
getVisibleItems(): readonly T[];
/**
* Orchestrator: composes `syncEmptyStateToDom` + `commitTriggerContentToDom`
* to (re)build the trigger from state; touches no DOM directly. Default text
* is a count summary; override (or pass the `createTriggerContentElFn`
* setting) to display tags / custom markup / etc.
*
* - If 0 items are chosen, the text is `placeholder`.
* - If n > 0, the text is `uiTranslationPack.triggerCountSummary(n, total)`
* (English default: `"n / total selected"`, or `"All n selected"` when
* all are chosen).
* @group Subclassing: rendering
*/
protected renderTriggerContent(): void;
/**
* Build the tag-list element for `'tags'` mode: one chip per chosen item.
* Override for full control of the chip strip (the trigger-level equivalent
* of overriding `createItemEl`).
* @group Subclassing: rendering
*/
protected createTagsEl(): HTMLElement;
/**
* Build one removable tag chip: its content (from `createTagContentEl`, else
* plain `itemToString`) plus its remove (x) button (from `createTagRemoveButtonEl`).
* A chip whose item is effectively disabled gets `aria-disabled="true"` +
* `tagDisabledClass`, and its x turns inert - mirroring a disabled option row.
* Override for full control of the chip container; override the two sub-parts
* for content-only / remove-button-only changes.
* @group Subclassing: rendering
*/
protected createTagEl(item: T): HTMLElement;
/**
* Build one chip's remove (x) button. The library owns the button + its click
* (`stopPropagation` so it never toggles the popup, then `toggleItem`; a no-op
* while the whole control OR the item itself is disabled) + `tabindex="-1"` +
* `aria-label` (from `itemToTagRemoveButtonAriaLabel`). An effectively-disabled
* item's button also gets `aria-disabled="true"`.
* `createTagRemoveButtonContentElFn` optionally fills the icon, else the theme's CSS glyph.
* Mirrors the clear button's `createTriggerClearButtonEl`. Override for full control of
* the button element.
* @group Subclassing: rendering
*/
protected createTagRemoveButtonEl(item: T): HTMLElement;
/**
* One chip's remove-button visible content (its x icon). Mirrors
* `createTriggerClearButtonContentEl`.
* - Default reads `createTagRemoveButtonContentElFn`; `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 createTagRemoveButtonContentEl(item: T): HTMLElement | SVGElement | null;
/**
* Per-chip visible content in `'tags'` mode. Mirrors `createItemContentEl`.
* Default reads `createTagContentElFn`, else `null` so `createTagEl` falls
* back to plain text from `itemToString`.
* @group Subclassing: rendering
*/
protected createTagContentEl(item: T): HTMLElement | null;
/**
* Item -> its remove button's accessible name in `'tags'` mode.
* - Default: `uiTranslationPack.tagRemoveButtonAriaLabel(itemToString(item))`.
* - Override only when extending (e.g. a name from another item field);
* per-locale text goes through the `uiTranslationPack` setting.
* @group Subclassing: semantics
*/
protected itemToTagRemoveButtonAriaLabel(item: T): string;
/**
* No selection iff the chosen set is empty. Drives the trigger's `data-empty`.
* @group Subclassing: semantics
*/
protected isEmpty(): boolean;
/**
* Toggle on click. Multi mode keeps the popup open.
* @group Subclassing: reactions
*/
protected onItemActivated(item: T): void;
/**
* Clear button empties the chosen-items set to `[]`.
* @group Subclassing: semantics
*/
protected clearSelection(): void;
/**
* Build the choose-all row (`chooseAllRow` setting) as the listbox's
* leading `role="option"` row: `data-chosen-state="none|some|all"` (a CSS
* styling hook), `aria-selected` only when ALL visible
* enabled items are chosen, accessible name + visible text from
* `uiTranslationPack.chooseAllRowText(chosenCount, totalCount)` over the visible
* enabled subset. `null` when the setting is off or nothing is actionable.
* @group Subclassing: rendering
*/
protected createPopupListLeadingRowEl(): HTMLElement | null;
/**
* The choose-all row's visible content (rich tri-state). Mirrors
* `createItemContentEl`.
* - Default reads `createChooseAllRowContentElFn`; `null` (setting unset,
* or returned) = the default content: plain text from
* `uiTranslationPack.chooseAllRowText`.
* - Override only when extending; for one-off content pass the setting.
* @group Subclassing: rendering
*/
protected createChooseAllRowContentEl(chosenState: LLSelectChosenState, chosenCount: number, totalCount: number): HTMLElement | null;
/**
* Activate the choose-all row: delegates to {@link toggleAllVisible}.
* @group Subclassing: reactions
*/
protected onLeadingRowActivated(): void;
/**
* Mark each item with `aria-selected` reflecting its chosen state.
* @group Subclassing: rendering
*/
protected createItemEl(item: T, index: number): HTMLElement;
/**
* Re-match the chosen entries against the new list after `setItems`.
* - Entries the list no longer holds (by `compareFn`) are dropped and
* `onChange` fires for the drop.
* - When the list holds a compareFn-equal but DIFFERENT object (`track by`
* style reload: same key, fresh fields), the stored reference is swapped
* to the list's object. A reference swap is not a logical change, so it
* does not fire `onChange`.
* - The trigger content re-renders after every `setItems`, swap or not.
* - Why: the count summary shows the list total, and a custom
* `createTriggerContentElFn` receives `items`.
* - The arrow re-renders only when a chosen entry is dropped, because that
* runs the whole trigger.
* - `triggerDisplay: 'tags'` is opt-in; `'count'` is the default.
* - When `triggerDisplay` is `'tags'`, that render is one chip per chosen
* item per `setItems`, unless `createTriggerContentElFn` replaces the
* content.
* - That cost is acceptable: `setItems` is a bulk call.
* @group Subclassing: reactions
*/
protected onItemsChanged(): void;
/**
* On open, focus the first chosen item (if present and enabled). Otherwise
* the FIRST OPTION - which is the choose-all row when rendered (A11Y.md:
* activedescendant points at the first chosen option, else the first
* option; the row is the topmost option), so keyboard users discover it
* immediately. Else the first enabled item. Indices are into
* `getVisibleItems()`.
* @group Subclassing: focus
*/
protected focusInitial(): void;
private arraysEqual;
private fireChange;
}
//# sourceMappingURL=multiple.d.ts.map