UNPKG

@llselect/core

Version:

Low-level select: a minimal, flexible, framework-agnostic replacement for the native HTML select element.

462 lines 22.9 kB
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