@llselect/core
Version:
Low-level select: a minimal, flexible, framework-agnostic replacement for the native HTML select element.
3,742 lines • 174 kB
JavaScript
// Positioner: places `floating` (popup) relative to `anchor` (trigger).
// Pure math is split into `computePosition` so it can be tested without layout.
const GAP = 4;
const VIEWPORT_PADDING = 8;
/**
* Compute where to place the floating element relative to the anchor.
*
* Vertical: prefers placing below; flips above when it does not fit below and
* either fits above or has more room above. When neither side fits, picks the
* side with more space and clamps `maxHeight` accordingly. A
* `currentPlacement` that still fits is kept (stickiness) - re-preferring
* "below" on every content change would make the popup jump sides whenever
* the list shrinks and regrows.
*
* Horizontal: `widthPolicy === 'fit-content'` (default) returns
* `width = max(anchor.width, floatingNaturalWidth)`, clamps to
* `viewport - 2 * VIEWPORT_PADDING`, and keeps the popup inside the viewport
* margins. Growth direction follows `direction`: ltr aligns left edges and
* grows rightward; rtl aligns RIGHT edges and grows leftward (the mirror).
*
* The "viewport" here is the VISIBLE window in client coordinates:
* `[viewportLeft, viewportLeft + viewportWidth]` x
* `[viewportTop, viewportTop + viewportHeight]`. The offsets are 0 except
* under pinch zoom (see {@link PositionInput.viewportLeft}).
* `widthPolicy === 'match-trigger'` returns `width = anchor.width` and
* `left = anchor.left` (no collision handling - popup is the same width as
* trigger; direction-independent).
*/
function computePosition(input) {
const { anchorRect, viewportWidth, viewportHeight, viewportLeft = 0, viewportTop = 0, floatingHeight, widthPolicy = 'fit-content', floatingNaturalWidth = 0, direction = 'ltr', currentPlacement, } = input;
const viewportBottom = viewportTop + viewportHeight;
const spaceBelow = viewportBottom - anchorRect.bottom - GAP - VIEWPORT_PADDING;
const spaceAbove = anchorRect.top - viewportTop - GAP - VIEWPORT_PADDING;
const fitsBelow = floatingHeight <= spaceBelow;
const fitsAbove = floatingHeight <= spaceAbove;
const currentStillFits = (currentPlacement === 'below' && fitsBelow) ||
(currentPlacement === 'above' && fitsAbove);
let placement;
if (currentStillFits && currentPlacement !== undefined) {
placement = currentPlacement;
}
else if (fitsBelow) {
placement = 'below';
}
else if (fitsAbove) {
placement = 'above';
}
else {
placement = spaceAbove > spaceBelow ? 'above' : 'below';
}
let top;
let maxHeight;
if (placement === 'below') {
top = anchorRect.bottom + GAP;
maxHeight = Math.max(0, viewportBottom - top - VIEWPORT_PADDING);
}
else {
maxHeight = Math.max(0, anchorRect.top - viewportTop - GAP - VIEWPORT_PADDING);
top = anchorRect.top - GAP - Math.min(floatingHeight, maxHeight);
}
let width;
let left;
if (widthPolicy === 'match-trigger') {
width = anchorRect.width;
left = anchorRect.left;
}
else {
const desiredWidth = Math.max(anchorRect.width, floatingNaturalWidth);
const maxAvailable = viewportWidth - 2 * VIEWPORT_PADDING;
width = Math.min(desiredWidth, Math.max(0, maxAvailable));
const leftEdge = viewportLeft + VIEWPORT_PADDING;
const rightEdge = viewportLeft + viewportWidth - VIEWPORT_PADDING;
if (direction === 'rtl') {
// Mirror of ltr: right edges aligned, growth goes leftward; push back
// inside the LEFT margin first, then clamp at the right one.
left = anchorRect.right - width;
if (left < leftEdge) {
left = leftEdge;
}
if (left + width > rightEdge) {
left = rightEdge - width;
}
}
else {
left = anchorRect.left;
if (left + width > rightEdge) {
left = rightEdge - width;
}
if (left < leftEdge) {
left = leftEdge;
}
}
}
return { top, left, width, maxHeight, placement };
}
// Walk ancestors and check whether any clipping ancestor (overflow != visible)
// hides the anchor. Catches the "anchor scrolled out of a scroll container"
// case that getBoundingClientRect alone misses, since the rect reports
// viewport coords regardless of ancestor clipping.
const CLIPPING_OVERFLOW = new Set(['hidden', 'auto', 'scroll', 'clip']);
function isClippedByAncestor(anchor, anchorRect) {
let p = anchor.parentElement;
while (p) {
const s = window.getComputedStyle(p);
if (CLIPPING_OVERFLOW.has(s.overflowX) || CLIPPING_OVERFLOW.has(s.overflowY)) {
const pRect = p.getBoundingClientRect();
if (anchorRect.bottom < pRect.top ||
anchorRect.top > pRect.bottom ||
anchorRect.right < pRect.left ||
anchorRect.left > pRect.right) {
return true;
}
}
p = p.parentElement;
}
return false;
}
// Anchor fully outside the LAYOUT viewport (window.innerWidth/Height, not the
// visual viewport - see reposition) or clipped away by a scroll ancestor.
// Strict comparisons so an unsized anchor at (0,0,0,0) - common in jsdom or
// before first layout - reads as "in viewport, no rect yet", not "off-screen".
function isAnchorHiddenForRect(anchor, rect) {
const outOfViewport = rect.bottom < 0 ||
rect.top > window.innerHeight ||
rect.right < 0 ||
rect.left > window.innerWidth;
return outOfViewport || isClippedByAncestor(anchor, rect);
}
/**
* Whether `anchor` is currently hidden (scrolled out of the layout viewport or
* clipped by a scrollable ancestor). Exposed so a caller can refuse to open a
* popup against an off-screen trigger BEFORE building a positioner, rather than
* opening and then hiding re-entrantly.
*/
function isAnchorHidden(anchor) {
return isAnchorHiddenForRect(anchor, anchor.getBoundingClientRect());
}
/**
* Measure the floating element's max-content (natural) width by briefly
* setting `width: max-content` and reading `offsetWidth`. Restores the prior
* inline width before returning. Used only in `'fit-content'` mode.
*/
function measureNaturalWidth(el) {
const prev = el.style.width;
el.style.width = 'max-content';
const w = el.offsetWidth;
el.style.width = prev;
return w;
}
/**
* Return the currently visible viewport size. On mobile, `window.innerHeight`
* reports the layout viewport (often larger than the actually-visible area
* when a URL bar or virtual keyboard takes part of the screen). Using
* `visualViewport` when present gives the real visible area, so the popup's
* `maxHeight` clamps to what the user can actually see.
*/
function getVisibleViewport() {
const vv = window.visualViewport;
// The offsets matter under pinch zoom: the visual viewport pans inside the
// layout viewport, while anchor rects and `position: fixed` stay in layout
// (client) coordinates. Both are 0 without zoom.
if (vv) {
return { left: vv.offsetLeft, top: vv.offsetTop, width: vv.width, height: vv.height };
}
return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
}
/**
* Attach a positioner that keeps `floating` placed relative to `anchor`.
*
* Behavior: sets `floating` to `position: fixed`, listens to window scroll
* (capture phase, so any ancestor scroll is caught), window resize, and
* `ResizeObserver` on both elements. On every reposition: if the anchor is
* outside the layout viewport or clipped by a scrollable ancestor and `onHide` is
* provided, calls `onHide` and skips style updates. Otherwise applies the
* coordinates from {@link computePosition} and sets `data-placement` on
* `floating` for CSS hooks.
*
* Caller is responsible for calling `detach()` when the floating element is
* dismissed; otherwise listeners leak.
*/
function createPositioner(anchor, floating, options) {
let attached = true;
const widthPolicy = options?.widthPolicy ?? 'fit-content';
// Snapshot at attach (= once per open cycle): direction changes are rare
// and the next open re-reads it. Only fit-content consults it.
const direction = window.getComputedStyle(anchor).direction === 'rtl' ? 'rtl' : 'ltr';
// Placement chosen by the previous reposition, fed back for stickiness.
// Positioner lifetime = one open cycle, so the next open picks fresh.
let lastPlacement;
// `allowHide`: whether this reposition may invoke `onHide` (auto-close).
// Only scroll-driven repositions (and the initial placement) close the popup
// when the anchor leaves view; resize-driven ones never do - see onResize.
function reposition(allowHide) {
if (!attached) {
return;
}
const rect = anchor.getBoundingClientRect();
const { left: viewportLeft, top: viewportTop, width: viewportWidth, height: viewportHeight } = getVisibleViewport();
// Visibility test uses the LAYOUT viewport (window.innerWidth/Height), NOT
// the visual viewport. A virtual keyboard / URL bar shrinks the visual
// viewport from the bottom without scrolling the anchor away, while
// getBoundingClientRect reports layout-viewport coords - comparing the two
// spaces would treat a lower-screen anchor as "scrolled out" and close the
// popup the instant the keyboard opens. The visual viewport still drives
// computePosition below (maxHeight clamps to the actually-visible area).
if (allowHide && isAnchorHiddenForRect(anchor, rect) && options?.onHide) {
options.onHide();
return;
}
const floatingNaturalWidth = widthPolicy === 'fit-content'
? measureNaturalWidth(floating)
: 0;
// Natural (unclamped) height, measured by reads only. offsetHeight under
// an active maxHeight clamp feeds the clamp back into the fits test -
// after a flip to the smaller side the popup could then never measure
// taller than that side and stayed stuck there even when the list grew
// back (regrown filter results kept a bottom-flipped popup squeezed at
// the viewport edge). The clamp swallows exactly the inner scroller's
// overflow, so adding it back reconstructs the natural height; see
// `PositionerOptions.innerScrollEl` for why the clamp must not be lifted
// to re-measure instead.
const inner = options?.innerScrollEl;
const clampedOverflow = inner ? Math.max(0, inner.scrollHeight - inner.clientHeight) : 0;
const floatingHeight = floating.offsetHeight + clampedOverflow;
const result = computePosition({
anchorRect: {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
},
viewportWidth,
viewportHeight,
viewportLeft,
viewportTop,
floatingHeight,
widthPolicy,
floatingNaturalWidth,
direction,
currentPlacement: lastPlacement,
});
lastPlacement = result.placement;
floating.style.position = 'fixed';
floating.style.top = `${result.top}px`;
floating.style.left = `${result.left}px`;
floating.style.width = `${result.width}px`;
floating.style.maxHeight = `${result.maxHeight}px`;
floating.setAttribute('data-placement', result.placement);
}
// Scroll = the anchor moving through the viewport: honor onHide so a popup
// whose trigger scrolled away is dismissed.
const onScroll = () => reposition(true);
// Resize-class events (window resize, visualViewport resize from a virtual
// keyboard / URL bar, element resize) change available space but do NOT mean
// the user scrolled the trigger away. Reposition / re-clamp only, never close
// - otherwise opening the on-screen keyboard would instantly dismiss the popup.
const onResize = () => reposition(false);
// Capture phase catches scrolls inside any ancestor scrollable container.
window.addEventListener('scroll', onScroll, { passive: true, capture: true });
window.addEventListener('resize', onResize);
// Mobile: URL bar showing/hiding and virtual keyboard appearing change the
// visual viewport without firing window resize. Listen to visualViewport
// RESIZE only so we re-clamp maxHeight to the actually-visible area. We
// deliberately do NOT listen to `visualViewport.scroll`: that fires during
// pinch-pan and during the URL-bar collapse animation. The popup is
// `position: fixed` (layout-viewport-anchored) so the trigger and the popup
// pan together and no reposition is needed; listening would just cause the
// popup to bounce during the URL-bar animation (observed on Firefox Android).
const vv = window.visualViewport;
vv?.addEventListener('resize', onResize);
let ro;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => reposition(false));
ro.observe(anchor);
ro.observe(floating);
}
reposition(true);
return {
// Public re-place: never auto-closes. Called after list re-renders (incl.
// every filter keystroke), where dismissing would be wrong - especially on
// mobile with the keyboard open. Dismissal is a scroll-only decision.
reposition: () => reposition(false),
detach() {
attached = false;
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
vv?.removeEventListener('resize', onResize);
ro?.disconnect();
floating.style.position = '';
floating.style.top = '';
floating.style.left = '';
floating.style.width = '';
floating.style.maxHeight = '';
floating.removeAttribute('data-placement');
},
};
}
// Pure grouping helper, used by LLSelectBase (the `gatherGroups` setting) and
// exported for callers who pre-gather their own data.
/**
* Stable-bucket `items` so that every group is contiguous, without touching
* the caller's array.
*
* - Groups appear in order of each key's FIRST appearance.
* - Within a group, items keep their relative order.
* - Items whose key is `null` (ungrouped) form their own single-item segment
* at their walk position; they never merge.
* - Already-contiguous input is detected in one scan and returned AS-IS (the
* input array itself, no copy); otherwise a new array is returned.
*
* This is exactly what `LLSelectBase` runs internally while the
* `gatherGroups` setting is on (the default). Exported for callers who switch
* `gatherGroups` off and gather once themselves (e.g. ahead of many
* `setItems` calls on the same data).
*
* @param items - the item list to gather
* @param itemToGroupKeyFn - item to group key; `null` = the item is in no group
* @param groupKeyCompareFn - key equality; `null` / omitted = identity (`===`,
* plus `NaN` equals `NaN` - the rule the internal `Map` uses; same contract
* as the `groupKeyCompareFn` setting)
* @group Grouping
*/
function gatherItemsByGroupKey(items, itemToGroupKeyFn, groupKeyCompareFn) {
const eq = groupKeyCompareFn ?? null;
// Seen-key lookup: Map for the default identity (O(1) per item), linear
// scan over first-seen keys for a custom predicate (a Map cannot key by
// predicate).
const seenMap = eq === null ? new Map() : null;
const seenList = [];
const hasSeen = (k) => (seenMap !== null ? seenMap.has(k) : seenList.some(s => eq(s, k)));
const keyEquals = (a, b) => (eq !== null ? eq(a, b) : a === b || (a !== a && b !== b));
// Pass 1, detect only (no per-item allocation): contiguous means every
// non-null key either equals the previous item's key or was never seen.
let contiguous = true;
let prevKey = null;
for (const item of items) {
const key = itemToGroupKeyFn(item);
if (key === null) {
prevKey = null;
continue;
}
if (prevKey !== null && keyEquals(prevKey, key)) {
continue;
}
if (hasSeen(key)) {
contiguous = false;
break;
}
if (seenMap !== null) {
seenMap.set(key, true);
}
else {
seenList.push(key);
}
prevKey = key;
}
if (contiguous) {
return items;
}
// Pass 2, rebuild: one bucket per key in first-appearance order; null-key
// items are their own single-item segment.
const segments = [];
const bucketMap = eq === null ? new Map() : null;
const keyedBuckets = [];
for (const item of items) {
const key = itemToGroupKeyFn(item);
if (key === null) {
segments.push([item]);
continue;
}
let bucket = bucketMap !== null ? bucketMap.get(key) : keyedBuckets.find(b => eq(b.key, key))?.bucket;
if (bucket === undefined) {
bucket = [];
segments.push(bucket);
if (bucketMap !== null) {
bucketMap.set(key, bucket);
}
else {
keyedBuckets.push({ key, bucket });
}
}
bucket.push(item);
}
const out = [];
for (const bucket of segments) {
out.push(...bucket);
}
return out;
}
// KeyboardEvent -> action mapping for the trigger element.
// Pure logic so it can be tested without a DOM.
/**
* Logical actions a keyboard interaction can map to. Numeric values are
* implementation detail; never serialise them.
*/
var LLSelectAction;
(function (LLSelectAction) {
/** Open the popup (no item activation). */
LLSelectAction[LLSelectAction["Open"] = 0] = "Open";
/** Close the popup (no item activation). */
LLSelectAction[LLSelectAction["Close"] = 1] = "Close";
/** Activate the currently focused option (select + maybe close). */
LLSelectAction[LLSelectAction["Select"] = 2] = "Select";
/** Move focus to the next option (clamps at last). */
LLSelectAction[LLSelectAction["Next"] = 3] = "Next";
/** Move focus to the previous option (clamps at first). */
LLSelectAction[LLSelectAction["Previous"] = 4] = "Previous";
/** Move focus to the first option. */
LLSelectAction[LLSelectAction["GotoFirst"] = 5] = "GotoFirst";
/** Move focus to the last option. */
LLSelectAction[LLSelectAction["GotoLast"] = 6] = "GotoLast";
/** Jump focus down by a fixed page size. */
LLSelectAction[LLSelectAction["PageDown"] = 7] = "PageDown";
/** Jump focus up by a fixed page size. */
LLSelectAction[LLSelectAction["PageUp"] = 8] = "PageUp";
})(LLSelectAction || (LLSelectAction = {}));
const PAGE_SIZE = 10;
/**
* Map a keydown event to a logical {@link LLSelectAction}, given whether the
* popup is currently open.
* - Returns `undefined` if the key should be left alone (no preventDefault,
* no library reaction).
* - Maps the action keys of the ARIA APG combobox pattern.
* - The pattern's printable-character typeahead is not mapped here - it needs
* the character, which an action enum cannot carry. The keydown handler
* runs {@link findTypeaheadIndex} before this mapping.
*
* @param inTextInput - true when focus is in the editable filter input. There,
* Space must type a space and Home/End must move the text caret, so those
* keys are NOT mapped to selection / first-last navigation. Selection is
* Enter only; option navigation is the arrow / page keys.
*/
function getActionFromKey(ev, isOpened, inTextInput = false) {
const { key, altKey } = ev;
if (!isOpened) {
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ') {
return LLSelectAction.Open;
}
return undefined;
}
if (key === 'Escape') {
return LLSelectAction.Close;
}
if (key === 'ArrowUp' && altKey) {
return LLSelectAction.Close;
}
if (key === 'Enter') {
return LLSelectAction.Select;
}
if (key === ' ' && !inTextInput) {
return LLSelectAction.Select;
}
if (key === 'ArrowDown') {
return LLSelectAction.Next;
}
if (key === 'ArrowUp') {
return LLSelectAction.Previous;
}
if (key === 'Home' && !inTextInput) {
return LLSelectAction.GotoFirst;
}
if (key === 'End' && !inTextInput) {
return LLSelectAction.GotoLast;
}
if (key === 'PageDown') {
return LLSelectAction.PageDown;
}
if (key === 'PageUp') {
return LLSelectAction.PageUp;
}
return undefined;
}
/**
* Compute a new focused-option index after applying a navigation action.
* Clamps to `[0, maxIndex]` (no wrap-around). Returns `-1` if there are no
* options (`maxIndex < 0`). The page size for PageUp/PageDown is a fixed
* constant.
*
* @param currentIndex - current focused index (`-1` for "none")
* @param maxIndex - largest valid index (`options.length - 1`)
*/
function getUpdatedIndex(currentIndex, maxIndex, action) {
if (maxIndex < 0) {
return -1;
}
switch (action) {
case LLSelectAction.GotoFirst: return 0;
case LLSelectAction.GotoLast: return maxIndex;
case LLSelectAction.Next: return Math.min(currentIndex + 1, maxIndex);
case LLSelectAction.Previous: return Math.max(currentIndex - 1, 0);
case LLSelectAction.PageDown: return Math.min(currentIndex + PAGE_SIZE, maxIndex);
case LLSelectAction.PageUp: return Math.max(currentIndex - PAGE_SIZE, 0);
default: return currentIndex;
}
}
/**
* Pause (in ms) after which the next typed character starts a new typeahead
* buffer instead of extending the old one. Native `<select>` implementations
* use 1 s (Blink / WebKit).
*/
const TYPEAHEAD_TIMEOUT_MS = 1000;
/**
* Compute the typeahead buffer after a newly typed character.
* - If `elapsedMs` since the previous character exceeds
* {@link TYPEAHEAD_TIMEOUT_MS}, the character starts a fresh buffer.
*/
function getUpdatedTypeaheadBuffer(buffer, char, elapsedMs) {
return elapsedMs > TYPEAHEAD_TIMEOUT_MS ? char : buffer + char;
}
/**
* Resolve where prefix typeahead moves the active option; `-1` = no match.
* Mirrors native `<select>` typeahead:
* - An option matches when its text starts with `buffer`, case-insensitive.
* - A one-character buffer searches from the option AFTER `currentIndex`, so
* repeated presses of one initial cycle through the options sharing it.
* - A buffer of one repeated character (e.g. `"aa"`) behaves exactly like its
* single character - it keeps cycling. It is never matched literally (the
* W3C APG example tries the literal `"aa"` prefix first; native does not,
* and a text really starting `"aa"` is still reached by the cycle).
* - Any other longer buffer searches from `currentIndex` itself, so extending
* the buffer stays on the current option while it still matches.
* - The search wraps around the whole list - deliberately, unlike the clamped
* arrow navigation (see `docs/llm/A11Y.md`): a search means "anywhere", and
* cycling needs the wrap.
* - Indexes where `textAt` returns `undefined` (disabled options) never match.
* - `currentIndex` `-1` means no option is active; the search starts at 0.
*
* The repeated-character test compares CODE POINTS, each lower-cased on its
* own: a key whose lower-case form expands to two code units (the Turkish
* dotted capital I) still counts, astral-plane characters compare whole, and
* a mid-repeat Shift ("aA") still cycles.
*
* @param textAt - match text of the option at an index, or `undefined` when
* that option must never match.
*/
function findTypeaheadIndex(buffer, count, currentIndex, textAt) {
if (count <= 0 || buffer === '') {
return -1;
}
// Both operands fold through foldForTypeahead. Its final-sigma map is what
// makes the two sides agree: whole-string `toLowerCase` applies the Greek
// Final_Sigma rule, folding a trailing sigma in the buffer differently from
// the same sigma mid-word in the option text. The per-code-point split is
// the backstop, in case another context-sensitive mapping ever lands.
const chars = [...buffer].map(foldForTypeahead);
const sameChar = chars.every((c) => c === chars[0]);
const needle = sameChar ? chars[0] : chars.join('');
const start = sameChar ? currentIndex + 1 : Math.max(currentIndex, 0);
const from = ((start % count) + count) % count;
for (let i = 0; i < count; i++) {
const idx = (from + i) % count;
const text = textAt(idx);
if (text !== undefined && foldForTypeahead(text).startsWith(needle)) {
return idx;
}
}
return -1;
}
/**
* Case-fold text for typeahead comparison, context-free per code point.
* - Lower-cases each code point on its own (never the whole string - see
* `findTypeaheadIndex`).
* - Maps the Greek final sigma (U+03C2, its own key on Greek keyboards) to
* sigma (U+03C3), so a typed final sigma matches upper-case text and a typed
* capital sigma matches lower-case text ending in the final form.
*/
function foldForTypeahead(text) {
return [...text].map((c) => c.toLowerCase()).join('').replace(/\u03c2/g, '\u03c3');
}
/**
* Scroll `scrollParent` just enough so `child` is fully visible. No-op if
* `child` is already in view. Adjusts `scrollTop` directly rather than using
* `scrollIntoView`, so the page (window) does not scroll alongside.
*
* Uses viewport-rect deltas, NOT `offsetTop`: `offsetTop` is relative to the
* offset parent, which a theme could change by making a group container
* `position: relative` (optgroup), silently breaking the math. Rect deltas are
* correct regardless of nesting / theme CSS. `clientTop` / `clientHeight`
* exclude the parent's border so a bordered list stays exact. Measured to cost
* the same as the old `offsetTop` path (see docs/llm/DESIGN.md "Optgroup").
*/
function ensureVisibleInScroll(child, scrollParent) {
const c = child.getBoundingClientRect();
const p = scrollParent.getBoundingClientRect();
const viewTop = p.top + scrollParent.clientTop;
const viewBottom = viewTop + scrollParent.clientHeight;
if (c.top < viewTop) {
scrollParent.scrollTop -= viewTop - c.top;
}
else if (c.bottom > viewBottom) {
scrollParent.scrollTop += c.bottom - viewBottom;
}
}
/**
* The built-in English pack - the library default.
* @group Language packs
*/
const en = {
triggerPlaceholder: 'Please select',
filterInputAriaLabel: 'Search',
filterInputPlaceholder: 'Filter (Esc to clear)',
popupListNoResults: 'No results found',
triggerClearButtonAriaLabel: 'Clear selection',
tagRemoveButtonAriaLabel: (itemText) => `Remove ${itemText}`,
triggerCountSummary: (chosenCount, totalCount) => chosenCount === totalCount ? `All ${chosenCount} selected` : `${chosenCount} / ${totalCount} selected`,
chooseAllRowText: (chosenCount, totalCount) => `Select all (${chosenCount} of ${totalCount})`,
};
// LLSelectBase: DOM scaffold, ARIA wiring, open/close state, item storage,
// keyboard navigation, and shared rendering primitives for LLSelectSingle and
// LLSelectMultiple. Subclasses own chosen-state and decide what happens on
// item click.
const DEFAULT_PREFIX = 'llselect';
let instanceCounter = 0;
/**
* 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.
*/
function defaultCompareFn(a, b) {
return a === b || (a !== a && b !== b);
}
/**
* Blank and whitespace-only name settings count as UNSET: the accessible-name
* computation skips an empty `aria-label` and moves on, so the ladder (and
* the unnamed warn) must do the same instead of treating `''` as "named".
*/
function blankToNull(value) {
return value == null || value.trim() === '' ? null : value;
}
/** Once-per-page guard for the unnamed-accessible-name warning. */
let warnedUnnamedName = false;
/** Once-per-page guard for the duplicate-items warning. */
let warnedDuplicateItems = false;
function createClassIdMap(prefix) {
const uniq = `${prefix}${++instanceCounter}`;
return {
rootClass: `${prefix}-root`,
triggerClass: `${prefix}-trigger`,
triggerContentClass: `${prefix}-trigger-content`,
triggerArrowClass: `${prefix}-trigger-arrow`,
triggerClearButtonClass: `${prefix}-trigger-clear-button`,
popupClass: `${prefix}-popup`,
popupListClass: `${prefix}-popup-list`,
popupListNoResultsClass: `${prefix}-popup-list-no-results`,
chooseAllRowClass: `${prefix}-choose-all-row`,
itemClass: `${prefix}-item`,
itemFocusedClass: `${prefix}-item-focused`,
itemDisabledClass: `${prefix}-item-disabled`,
groupClass: `${prefix}-group`,
groupLabelClass: `${prefix}-group-label`,
tagsClass: `${prefix}-tags`,
tagClass: `${prefix}-tag`,
tagRemoveButtonClass: `${prefix}-tag-remove-button`,
tagDisabledClass: `${prefix}-tag-disabled`,
openClass: `${prefix}-open`,
triggerId: `${uniq}-trigger`,
labelId: `${uniq}-label`,
triggerContentId: `${uniq}-trigger-content`,
triggerValueId: `${uniq}-trigger-value`,
popupListId: `${uniq}-popup-list`,
filterInputClass: `${prefix}-filter-input`,
filterInputId: `${uniq}-filter-input`,
};
}
/**
* 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
*/
class LLSelectBase {
/**
* @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, settings, subclassSettings) {
/** True when the constructor minted `classIdMap.labelId` onto `labelEl`; `destroy()` then removes it. */
this.labelElIdMinted = false;
/** Click handler bound to `labelEl`: focus the trigger, never open (native label parity). */
this.handleLabelElClick = () => { this.triggerEl.focus(); };
/**
* Current item list. Defensive copy of what `setItems` was given.
* @group State (protected)
*/
this.items = [];
/** Whether the popup is currently open; public reader {@link isOpened}. */
this.opened = false;
/**
* Index (into `items`) of the currently keyboard-focused item, or `-1`
* when nothing is focused (closed popup, or no items).
* @group State (protected)
*/
this.focusedIndex = -1;
/** Control-level disabled state (whole select); toggled via `setDisabled`. */
this.disabled = false;
/**
* 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)
*/
this.changeSource = 'api';
/** 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). */
this.triggerClearButtonEl = null;
this.itemEls = [];
/** Whether keyboard focus sits on the leading row (mutually exclusive with an item focus). */
this.leadingRowFocused = false;
/**
* 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.
*/
this.lastNoResultsText = null;
this.query = '';
// Prefix-typeahead state. Expiry is a timestamp delta checked on the next
// character (getUpdatedTypeaheadBuffer) - no timer to clean up. Reset by close()
// and by any mapped action key.
this.typeaheadBuffer = '';
this.typeaheadLastTime = 0;
this.composing = false;
// classIdMap first: labelEl id minting below needs labelId.
this.classIdMap = createClassIdMap(settings?.cssClassPrefix ?? DEFAULT_PREFIX);
// The pack resolves first: the placeholder's library default is localized
// chrome (uiTranslationPack.triggerPlaceholder), while an explicit `placeholder` is
// app copy and wins. The raw input placeholder is kept so
// setUiTranslationPack can re-run this exact resolution.
this.explicitPlaceholder = settings?.placeholder ?? null;
const uiTranslationPack = { ...en, ...settings?.uiTranslationPack };
// labelEl resolves before the name ladder: with neither `ariaLabelledBy`
// nor `ariaLabel` given, the label's id becomes the resolved
// `ariaLabelledBy`, and every downstream consumer (trigger chain, filter
// input, listbox) works unchanged.
const labelEl = settings?.labelEl ?? null;
if (labelEl !== null && labelEl.id === '') {
labelEl.id = this.classIdMap.labelId;
this.labelElIdMinted = true;
}
this.settings = {
cssClassPrefix: settings?.cssClassPrefix ?? DEFAULT_PREFIX,
placeholder: this.explicitPlaceholder ?? uiTranslationPack.triggerPlaceholder,
ariaLabel: blankToNull(settings?.ariaLabel),
ariaLabelledBy: blankToNull(settings?.ariaLabelledBy) ?? (blankToNull(settings?.ariaLabel) === null && labelEl !== null ? labelEl.id : null),
labelEl,
compareFn: settings?.compareFn ?? defaultCompareFn,
outsideClickBehavior: settings?.outsideClickBehavior ?? 'pass-through',
createTriggerArrowContentElFn: settings?.createTriggerArrowContentElFn ?? null,
clearable: settings?.clearable ?? false,
createTriggerClearButtonContentElFn: settings?.createTriggerClearButtonContentElFn ?? null,
filterable: settings?.filterable ?? false,
uiTranslationPack,
filterFn: settings?.filterFn ?? null,
createPopupListNoResultsContentElFn: settings?.createPopupListNoResultsContentElFn ?? null,
popupWidthPolicy: settings?.popupWidthPolicy ?? 'fit-content',
itemDisabledFn: settings?.itemDisabledFn ?? null,
focusableWhenDisabled: settings?.focusableWhenDisabled ?? false,
itemToStringFn: settings?.itemToStringFn ?? null,
createItemContentElFn: settings?.createItemContentElFn ?? null,
itemToGroupKeyFn: settings?.itemToGroupKeyFn ?? null,
gatherGroups: settings?.gatherGroups ?? true,
groupKeyCompareFn: settings?.groupKeyCompareFn ?? null,
groupKeyToStringFn: settings?.groupKeyToStringFn ?? null,
groupDisabledFn: settings?.groupDisabledFn ?? null,
createGroupLabelContentElFn: settings?.createGroupLabelContentElFn ?? null,
onOpen: settings?.onOpen ?? null,
onClose: settings?.onClose ?? null,
// Settings cast (one per constructor, see single / multiple): TS
// cannot prove "base fields + Omit<S, base keys>" reassembles a generic
// S. The channel itself is typed: the subclassSettings param accepts
// exactly the extra fields.
...subclassSettings,
};
// Loud failure over a silent a11y violation, like the group-order warn:
// an unnamed combobox violates WAI-ARIA 1.2 (A11Y.md, name ladder).
// Once per page, not per instance - a page full of unnamed widgets (a
// benchmark, a sandbox) must not flood the console nor pay a per-build
// cost; one nudge carries the rule.
if (!warnedUnnamedName && this.settings.ariaLabel === null && this.settings.ariaLabelledBy === null) {
warnedUnnamedName = true;
// The element rides along so DevTools can jump straight to the first
// offender instead of leaving the developer to hunt.
console.warn('llselect: this widget has no accessible name - pass ariaLabelledBy, ariaLabel, or labelEl. An unnamed combobox violates WAI-ARIA 1.2. (warned once per page; first offender:)', targetEl);
}
// Label click focuses the trigger (native <select> label behavior: focus
// only, never open). The one listener destroy() must undo outside the root.
if (labelEl !== null) {
labelEl.addEventListener('click', this.handleLabelElClick);
}
// Initial evaluation runs against the empty item list (setItems has not
// happened yet); every open() re-evaluates.
this.filterActive = this.computeFilterActive();
// Caller-passed element becomes root (preserves its id / external refs).
this.rootEl = targetEl;
this.rootEl.classList.add(this.classIdMap.rootClass);
// Opt the select subtree out of browser scroll-anchoring. Without this,
// showing/hiding the popup on first open after a page load can trigger
// a window scroll as the browser tries to keep an anchor element in
// place. The property excludes this element and all descendants from
// being eligible anchor nodes.
this.rootEl.style.overflowAnchor = 'none';
this.rootEl.replaceChildren();
this.triggerEl = this.createTriggerEl();
this.triggerContentEl = this.triggerEl.querySelector(`.${this.classIdMap.triggerContentClass}`);
this.triggerArrowEl = this.triggerEl.querySelector(`.${this.classIdMap.triggerArrowClass}`);
// Hidden plain-text mirror of the current value, kept in sync by
// commitTriggerContentToDom. The filterable-mode aria-labelledby chain
// references THIS span (not the content span) so labelled controls in rich
// content (tag remove buttons) never enter the field's accessible name.
// A root-level sibling, NOT inside triggerEl: hidden-but-referenced text
// still names the field, while `triggerEl.textContent` stays exactly the
// visible content (no doubled text for consumers reading it).
this.triggerValueEl = document.createElement('span');
this.triggerValueEl.id = this.classIdMap.triggerValueId;
this.triggerValueEl.hidden = true;
this.popupEl = this.createPopupEl();
this.popupListEl = this.createPopupListEl();
this.filterInputEl = this.createFilterInputEl();
// input always built; non-filterable keeps it `hidden`. The filter box must
// sit above the listbox: listbox children must be options only.
this.popupListNoResultsEl = this.createPopupListNoResultsEl();
this.popupEl.append(this.filterInputEl, this.popupListEl, this.popupListNoResultsEl);
this.popupEl.hidden = true;
this.syncFilterModeToDom();
// Force border-box on the popup elements so the positioner's max-height
// calculation stays correct regardless of the host page's box-sizing
// setting. Without this, themes with non-zero padding/border on the
// popup would render past the maxHeight set by the positioner and get
// clipped by the viewport edge.
this.popupEl.style.boxSizing = 'border-box';
this.popupListEl.style.boxSizing = 'border-box';
// popup-list takes the remaining vertical space inside popup and scrolls
// when items overflow. `min-height: 0` lets flex actually shrink it.
// These are safe to set in the constructor because they have no effect
// while the parent is `display: none`.
this.popupListEl.style.flex = '1';
this.popupListEl.style.minHeight = '0';
this.popupListEl.style.overflowY = 'auto';
// Top layer via the Popover API where it exists: while open the popup
// paints above every stacking context and ignores containing-block
// -creating ancestors (transform / filter / contain), WITHOUT moving in
// the DOM - so the rootEl.contains checks, inheritance, ARIA wiring and
// destroy() hold verbatim. 'manual' on purpose: it disables the
// browser's light dismiss, keeping this library's own outside-click /
// Esc logic the sole authority. Browsers without the API run the plain
// position:fixed path unchanged. See DESIGN.md "In-place popup".
this.popoverSupported = typeof this.popupEl.showPopover === 'function';
if (this.popoverSupported) {
this.popupEl.setAttribute('popover', 'manual');
}
this.rootEl.append(this.triggerEl, this.triggerValueEl, this.popupEl);
this.triggerEl.addEventListener('click', () => this.toggle());
this.triggerEl.addEventListener('keydown', (ev) => this.handleKeydown(ev));
// Hold DOM focus on the combobox host: a mousedown anywhere in the popup - an
// option, the no-results message, popup padding, or the list element itself
// (tabindex="-1", so click-focusable) - would move focus off the host and
// silently kill keyboard input (e.g. multi + filterable: mouse-toggle an item,
// then typing goes nowhere; or a padding/no-results mousedown blurs the host and
// focusout closes the popup). preventDefault keeps focus put; `click` still fires
// (it does not depend on the mousedown default), so selection is unaffected. The
// listener is on popupEl (not popupListEl) so it also covers the no-results
// element and padding. Exception: the filter input MUST take focus, so its
// subtree is let through. Native scrollbar dragging on the list is unaffected -
// a scrollbar mousedown is not a cancelable content event (verified in a real
// browser; see TODO.md).
this.popupEl.addEventListener('mousedown', (ev) => {
const t = this.eventTargetNode(ev);
if (t instanceof Node && this.filterInputEl.contains(t)) {
return;
}
ev.preventDefault();
});
// Always wired, regardless of the current filter mode: a `hidden` input
// receives no events, and the predicate form of `filterable` can activate
// the filter on any later open().
this.filterInputEl.addEventListener('keydown', (ev) => this.handleKeydown(ev));
this.filterInputEl.addEventListener('input', () => this.handleSearchInputEvent());
this.filterInputEl.addEventListener('compositionstart', () => { this.composing = true; });
this.filterInputEl.addEventListener('compositionend', () => { this.composing = false; this.handleSearchInputEvent(); });
}
/**
* Evaluate the `filterable` setting against the current items: booleans
* pass through, the predicate form is called with the full item list.
*/
computeFilterActive() {
const filterable = this.settings.filterable;
return typeof filterable === 'function' ? filterable(this.items) : filterable;
}
/**
* 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.
*/
syncFilterModeToDom() {
this.triggerEl.setAttribute('role', this.filterActive ? 'button' : 'combobox');
this.filterInputEl.hidden = !this.filterActive;
this.comboboxEl = this.filterActive ? this.filterInputEl : this.triggerEl;
this.syncFieldNameToDom();
this.syncTriggerTabindex();
}
/**
* 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.
*/
syncFieldNameToDom() {
const apply = (el, labelledBy, label) => {
if (labelledBy !== null) {
el.setAttribute('aria-labelledby', labelledBy);
}
else {
el.removeAttribute('aria-labelledby');
}
if (label !== null) {
el.setAttribute('aria-label', label);
}
else {
el.removeAttribute('aria-label');
}
};
const { ariaLabel, ariaLabelledBy } = this.settings;
const { triggerId, triggerValueId } = this.classIdMap;
if (ariaLabelledBy !== null) {
apply(this.triggerEl, this.filterActive ? `${ariaLabelledBy} ${triggerValueId}` : ariaLabelledBy, null);
apply(this.filterInputEl, ariaLabelledBy, null);
apply(this.popupListEl, ariaLabelledBy, null);
}
else if (ariaLabel !== null) {
apply(this.triggerEl, this.filterActive ? `${triggerId} ${triggerValueId}` : null, ariaLabel);
apply(this.filterInputEl, null, ariaLabel);
apply(this.popupListEl, null, ariaLabel);
}
else {
apply(this.triggerEl, null, null);
apply(this.filterInputEl, null, this.settings.uiTranslationPack.filterInputAriaLabel);
apply(this.popupListEl, null, null);
}
}
/**
* 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() {
if (this.opened || this.disabled) {
return;
}
// A trigger already scrolled out of view / clipped by an ancestor when
// open() runs cannot host a visible popup, so opening is a no-op (mirrors
// the disabled guard). This also prevents the positioner's initial
// synchronous placement from firing onHide -> close() re-entrantly before
// this.positioner is assigned and the listeners are attached - which would
// strand the outside-click / focusout / scroll / resize handlers with the
// control already reporting itself closed (destroy() -> close() then early-
// returns and cannot recover them).
if (isAnchorHidden(this.triggerEl)) {
return;
}
const restoreWindowScroll = this.captureWindowScroll();
this.opened = true;
// Filter mode is (re)evaluated once per open cycle, before anything that
// depends on it (role, focus host, filtering).
this.filterActive = this.computeFilterActive();
this.syncFilterModeToDom();
this.triggerEl.setAttribute('aria-expanded', 'true');
this.triggerEl.setAttribute('data-state', 'open');
this.rootEl.classList.add(this.classIdMap.openClass);
if (this.filterActive) {
this.query = '';
this.filterInputEl.value = '';
this.filterInputEl.setAttribute('aria-expanded', 'true');
this.recomputeFilteredItems();
}
// `position: fixed` MUST be set before `hidden = false`. Otherwise the
// popup is briefly an in-flow `display: flex` block while `renderPopupList`
// appends its items, which inflates document height by the popup's
// natural height; browsers can then run scroll-anchoring / URL-bar
// resize before the positioner takes over and shift window scroll, even
// though captureWindowScroll restores it at the end.
// (Layout / display can only be set while open: setting them in the
// constructor would override the `[hidden]` UA rule and leak the popup
// before first open.)
this.popupEl.style.position = 'fixed';
this.popupEl.style.display = 'flex';
this.popupEl.style.flexDirection = 'column';
this.popupEl.hidden = false;
if (this.popoverSupported && this.popupEl.isConnected) {
// Enter the top layer BEFORE anything measures: a popover not in its
// showing state is `display: none !important` (UA rule), so the
// positioner would measure 0. UA `[popover]` also sets `inset: 0`;
// the positioner's inline top/left override two edges, but the
// remaining `right/bottom: 0` over-constrain the box - and in an RTL
// containing block an over-constrained `left` LOSES to `right: 0` -
// so neutralize both. (isConnected: showPopover() throws on a
// disconnected element; a detached widget is invisible either way.)
this.popupEl.showPopover();
this.popupEl.style.right = 'auto';
this.popupEl.style.bottom = 'auto';
}
this.renderTriggerArrow();
this.renderPopupList();
this.positioner = createPositioner(this.triggerEl, this.popupEl, {
onHide: () => this.close(),
widthPolicy: this.settings.popupWidthPolicy,
innerScrollEl: this.popupListEl,
});
this.attachOutsideClick();
this.attachFocusOut();
this.focusInitial();
this.onOpened();
this.settings.onOpen?.();
if (this.filterActive) {
this.filterInputEl.focus({ preventScroll: true });
}
restoreWindowScroll();
}
/**
* 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() {
if (!this.opened) {
return;
}
const shouldReturnFocus = this.filterActive && this.isFocused(this.filterInputEl);
this.opened = false;
this.triggerEl.setAttribute('aria-expanded', 'false');
this.triggerEl.setAttribute('data-state', 'closed');
this.syncTriggerTabindex();
this.rootEl.classList.remove(this.classIdMap.openClass);
if (this.filterActive) {
this.filterInputEl.setAttribute('aria-expanded', 'false');
this.filterInputEl.value = '';
this.query = '';
this.filteredItems = undefined;
}
this.positioner?.detach();
this.positioner = undefined;
this.detachOutsideClick();
this.detachFocusOut();
this.popupListEl.replaceChildren();
// The no-results region hides with the popup; reset so a reopen with a
// still-empty list counts as a fresh appearance and re-announces.
this.lastNoResultsText = null;
if (this.popoverSupported) {
try {
this.popupEl.hidePopover();
}
catch {
// Already force-hidden without us (dialog.showModal() hides all
// popovers) - hidePopover() then throws InvalidStateError. State
// resyncs right here, so nothing else to do.
}
this.popupEl.style.right = '';
this.popupEl.style.bottom = '';
}
this.popupEl.hidden = true;
// Clear inline display + position so the `[hidden]` UA rule can hide the
// popup cleanly. (Position was set in open() to keep the popup out of
// flow before the positioner attached; positioner.detach() also clears
// it, but be explicit and symmetric with what open() set.)
this.popupEl.style.position = '';
this.popupEl.style.display = '';
this.popupEl.style.flexDirection = '';
this.itemEls = [];
this.focusedEl = undefined;
this.focusedIndex = -1;
this.typeaheadBuffer = '';
this.leadingRowEl = undefined;
this.leadingRowFocused = false;
this.comboboxEl.removeAttribute('aria-activedescendant');
this.renderTriggerArrow();
this.onClosed();
this.settings.onClose?.();
if (shouldReturnFocus) {
this.triggerEl.focus({ preventScroll: true });
}
}
/**
* 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() {
this.gatheredItems = undefined;
if (this.filterActive) {
this.recomputeFilteredItems();
}
this.renderTrigger();
if (this.opened) {
this.renderPopupList();
}
}
/**
* 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() {
this.close();
if (this.settings.labelEl !== null) {
this.settings.labelEl.removeEventListener('click', this.handleLabelElClick);
if (this.labelElIdMinted) {
this.settings.labelEl.removeAttribute('id');
}
}
this.rootEl.classList.remove(this.classIdMap.rootClass, this.classIdMap.openClass);
this.rootEl.style.overflowAnchor = '';
this.rootEl.replaceChildren();
}
/**
* Open if closed, close if open.
* @group Open & close
*/
toggle() {
if (this.opened) {
this.close();
}
else {
this.open();
}
}
/**
* 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() {
return this.opened;
}
/**
* 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() {
return this.items;
}
/**
* 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() {
return this.settings.uiTranslationPack;
}
/**
* 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) {
const pack = { ...en, ...uiTranslationPack };
this.settings.uiTranslationPack = pack;
this.settings.placeholder = this.explicitPlaceholder ?? pack.triggerPlaceholder;
// Pack-owned attributes rerender() cannot reach:
this.syncFieldNameToDom();
if (pack.filterInputPlaceholder !== null) {
this.filterInputEl.placeholder = pack.filterInputPlaceholder;
}
else {
this.filterInputEl.removeAttribute('placeholder');
}
this.rerender();
}
/**
* 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) {
if (this.explicitPlaceholder === placeholder) {
return;
}
this.explicitPlaceholder = placeholder;
this.settings.placeholder = placeholder ?? this.settings.uiTranslationPack.triggerPlaceholder;
this.renderTrigger();
}
/**
* 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) {
this.items = items.slice();
this.warnOnDuplicateItems();
this.gatheredItems = undefined;
if (this.filterActive) {
this.recomputeFilteredItems();
}
if (this.opened) {
this.renderPopupList();
}
this.onItemsChanged();
}
/**
* 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.
*/
warnOnDuplicateItems() {
if (warnedDuplicateItems || this.settings.compareFn !== defaultCompareFn) {
return;
}
if (new Set(this.items).size === this.items.length) {
return;
}
warnedDuplicateItems = true;
console.warn('llselect: duplicate items passed to setItems - items must be unique under compareFn (it defines item identity, and the selection is a set). Duplicates render stale selection state. (warned once per page)');
}
/**
* 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) {
if (this.disabled === value) {
return;
}
this.disabled = value;
if (value && this.opened) {
this.close();
}
this.syncDisabledStateToDom();
}
/**
* Whether the whole control is disabled.
* @group Disabling
*/
isDisabled() {
return this.disabled;
}
/** Reflect `this.disabled` onto the trigger's ARIA / data / tabindex. */
syncDisabledStateToDom() {
if (this.disabled) {
this.triggerEl.setAttribute('aria-disabled', 'true');
this.triggerEl.setAttribute('data-disabled', 'true');
}
else {
this.triggerEl.removeAttribute('aria-disabled');
this.triggerEl.setAttribute('data-disabled', 'false');
}
this.syncTriggerTabindex();
}
/**
* 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").
*/
syncTriggerTabindex() {
const disabledAndUnfocusable = this.disabled && !this.settings.focusableWhenDisabled;
const filterOwnsFocus = this.opened && this.filterActive;
this.triggerEl.setAttribute('tabindex', disabledAndUnfocusable || filterOwnsFocus ? '-1' : '0');
}
/**
* 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
*/
onOpened() { }
/**
* Subclass hook: called once after the popup finishes closing. Pairs with the `onClose` setting (both run).
* @group Subclassing: reactions
*/
onClosed() { }
/**
* 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
*/
onChosenChanged() { }
/**
* 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
*/
onItemsChanged() { }
/**
* 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
*/
renderTrigger() {
this.replaceTriggerClearButtonElInDom();
this.renderTriggerContent();
this.renderTriggerArrow();
}
/**
* 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
*/
commitTriggerContentToDom(content, plainTextValue) {
if (typeof content === 'string') {
this.triggerContentEl.textContent = content;
this.triggerValueEl.textContent = content;
}
else {
this.triggerContentEl.replaceChildren(content);
this.triggerValueEl.textContent = plainTextValue ?? content.textContent;
}
}
/**
* 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
*/
syncEmptyStateToDom() {
this.triggerEl.setAttribute('data-empty', this.isEmpty() ? 'true' : 'false');
}
/**
* 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
*/
isEmpty() {
return true;
}
/**
* 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
*/
renderTriggerContent() {
this.syncEmptyStateToDom();
this.commitTriggerContentToDom(this.settings.placeholder);
}
/**
* 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).
*/
renderTriggerArrow() {
this.commitTriggerArrowContentElToDom(this.createTriggerArrowContentEl({ isOpened: this.opened }));
}
/**
* 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
*/
createTriggerArrowContentEl(state) {
return this.settings.createTriggerArrowContentElFn ? this.settings.createTriggerArrowContentElFn(state) : 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`.
*/
commitTriggerArrowContentElToDom(el) {
this.triggerArrowEl.replaceChildren();
if (el) {
this.triggerArrowEl.appendChild(el);
}
}
/**
* 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
*/
renderPopupList() {
const list = this.getVisibleItems();
const els = list.map((item, i) => this.createItemEl(item, i));
this.itemEls = els;
this.focusedEl = undefined;
this.leadingRowEl = this.createPopupListLeadingRowEl() ?? undefined;
if (!this.leadingRowEl) {
this.leadingRowFocused = false;
}
this.commitPopupSegmentsToDom(this.computePopupSegments(list, els));
this.syncPopupListNoResultsToDom();
this.positioner?.reposition();
// Clamp focused index if the visible list shrank, then re-apply visuals.
// The clamp seeks BACKWARD to an enabled row: landing focus on a
// disabled one would break the disabled-skip contract (A11Y.md).
if (this.focusedIndex >= list.length) {
this.focusedIndex = list.length === 0 ? -1 : this.findNextEnabledIndex(list.length - 1, -1, list);
}
else if (this.focusedIndex >= 0 && this.isItemEffectivelyDisabled(list[this.focusedIndex])) {
// Same contract when the row at the focused index BECAME disabled
// (setItems swapped the item in place): seek backward - the option
// above the first item is the choose-all leading row when present
// (A11Y.md ring order) - else forward.
const back = this.findNextEnabledIndex(this.focusedIndex, -1, list);
if (back >= 0) {
this.focusedIndex = back;
}
else if (!this.focusLeadingRow()) {
this.focusedIndex = this.findNextEnabledIndex(this.focusedIndex, 1, list);
}
}
this.syncFocusedIndexToDom();
}
/**
* 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.
*/
computePopupSegments(list, els) {
const keyEq = this.settings.groupKeyCompareFn ?? defaultCompareFn;
const segments = [];
const closedKeys = [];
let groupIndex = 0;
let i = 0;
while (i < list.length) {
const key = this.itemToGroupKey(list[i]);
if (key === null) {
segments.push({ group: false, el: els[i] });
i += 1;
continue;
}
const groupItems = [list[i]];
const groupEls = [els[i]];
let j = i + 1;
while (j < list.length) {
const next = this.itemToGroupKey(list[j]);
if (next === null || !keyEq(key, next)) {
break;
}
groupItems.push(list[j]);
groupEls.push(els[j]);
j += 1;
}
if (closedKeys.some(k => keyEq(k, key))) {
console.warn('llselect: group key reappears non-contiguously; sort items by group to avoid a duplicate header.', key);
}
closedKeys.push(key);
segments.push({ group: true, key, index: groupIndex, items: groupItems, els: groupEls });
groupIndex += 1;
i = j;
}
return segments;
}
/** Replace every popup-list child with the leading row (when present) + the rendered segments. */
commitPopupSegmentsToDom(segments) {
const children = segments.map(seg => seg.group ? this.createGroupEl(seg.key, seg.index, seg.items, seg.els) : seg.el);
if (this.leadingRowEl) {
children.unshift(this.leadingRowEl);
}
this.popupListEl.replaceChildren(...children);
}
/**
* 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
*/
createGroupEl(key, index, items, itemEls) {
const text = this.groupKeyToString(key);
const group = document.createElement('div');
group.id = `${this.classIdMap.popupListId}-group${index}`;
group.className = this.classIdMap.groupClass;
group.setAttribute('role', 'group');
group.setAttribute('aria-label', text);
if (this.isGroupDisabled(key)) {
group.setAttribute('aria-disabled', 'true');
group.setAttribute('data-disabled', 'true');
}
const labelEl = document.createElement('div');
labelEl.className = this.classIdMap.groupLabelClass;
labelEl.setAttribute('aria-hidden', 'true');
const content = this.createGroupLabelContentEl(key, items);
if (content === null) {
labelEl.textContent = text;
}
else {
labelEl.appendChild(content);
}
group.append(labelEl, ...itemEls);
return group;
}
/**
* 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
*/
createGroupLabelContentEl(key, itemsInGroup) {
return this.settings.createGroupLabelContentElFn
? this.settings.createGroupLabelContentElFn(key, itemsInGroup)
: 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
*/
replacePopupListItemElInDom(item) {
if (!this.opened) {
return;
}
const list = this.getVisibleItems();
const index = list.findIndex(i => this.settings.compareFn(i, item));
if (index < 0) {
return;
}
const oldEl = this.itemEls[index];
if (oldEl === undefined) {
return;
}
const newEl = this.createItemEl(list[index], index);
oldEl.replaceWith(newEl);
this.itemEls[index] = newEl;
// Preserve focus visuals if the replaced element was the focused one.
if (this.focusedEl === oldEl) {
newEl.classList.add(this.classIdMap.itemFocusedClass);
this.comboboxEl.setAttribute('aria-activedescendant', newEl.id);
this.focusedEl = newEl;
}
}
/**
* 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
*/
createItemEl(item, index) {
const el = document.createElement('div');
// Ids hang off the listbox id: options belong to the listbox, not the trigger.
el.id = `${this.classIdMap.popupListId}-item${index}`;
el.className = this.classIdMap.itemClass;
el.setAttribute('role', 'option');
const content = this.createItemContentEl(item);
if (content === null) {
el.textContent = this.itemToString(item);
}
else {
// Custom content fills the visuals only. The accessible name + match
// text always come from itemToString, so pin aria-label to it: stays
// consistent with the plain-text branch (textContent === itemToString)
// and the caller never touches aria-* themselves.
el.setAttribute('aria-label', this.itemToString(item));
el.appendChild(content);
}
// No `title` attribute by default: items wrap (themes default), so the
// full text is already visible and a tooltip is redundant. Adding
// `title` would also fight third-party tooltip libraries (Tippy etc.).
// Users who opt into ellipsis-on-items pick their own tooltip mechanism.
if (this.isItemEffectivelyDisabled(item)) {
// `aria-disabled` (never native `disabled`) keeps the item perceivable and
// hoverable for a "why disabled" tooltip. No click handler -> not
// selectable; keyboard nav skips it too.
el.setAttribute('aria-disabled', 'true');
el.classList.add(this.classIdMap.itemDisabledClass);
}
else {
el.addEventListener('click', () => {
// Move focus to the clicked item before activating it. Without this,
// multi mode (which keeps the popup open) leaves the focused styling
// on the previous keyboard-focused item while a different one was
// just clicked.
this.setFocusedIndex(index);
this.withUserChangeSource(() => this.onItemActivated(item));
});
}
return el;
}
/**
* 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
*/
itemToString(item) {
return this.settings.itemToStringFn ? this.settings.itemToStringFn(item) : String(item);
}
/**
* 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
*/
createItemContentEl(item) {
return this.settings.createItemContentElFn ? this.settings.createItemContentElFn(item) : 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
*/
isItemEffectivelyDisabled(item) {
if (this.settings.itemDisabledFn && this.settings.itemDisabledFn(item)) {
return true;
}
const key = this.itemToGroupKey(item);
return key !== null && this.isGroupDisabled(key);
}
/**
* 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
*/
itemToGroupKey(item) {
return this.settings.itemToGroupKeyFn ? this.settings.itemToGroupKeyFn(item) : null;
}
/**
* Map a group key to its header display text.
* - Default reads `groupKeyToStringFn`, else `String(key)`.
* @group Subclassing: semantics
*/
groupKeyToString(key) {
return this.settings.groupKeyToStringFn ? this.settings.groupKeyToStringFn(key) : String(key);
}
/**
* Whether the whole group `key` is disabled per `groupDisabledFn` (false when unset).
* @group Subclassing: semantics
*/
isGroupDisabled(key) {
return this.settings.groupDisabledFn ? this.settings.groupDisabledFn(key) : false;
}
/**
* 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
*/
findNextEnabledIndex(start, step, list) {
for (let i = start; i >= 0 && i < list.length; i += step) {
if (!this.isItemEffectivelyDisabled(list[i])) {
return i;
}
}
return -1;
}
/**
* 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.
*/
findEnabledIndexForAction(target, action, list) {
const forward = action === LLSelectAction.Next
|| action === LLSelectAction.GotoFirst
|| action === LLSelectAction.PageDown;
const primary = this.findNextEnabledIndex(target, forward ? 1 : -1, list);
if (primary >= 0) {
return primary;
}
if (action === LLSelectAction.PageDown) {
return this.findNextEnabledIndex(target, -1, list);
}
if (action === LLSelectAction.PageUp) {
return this.findNextEnabledIndex(target, 1, list);
}
return -1;
}
/**
* 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
*/
onItemActivated(_item) { }
/**
* 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
*/
createPopupListLeadingRowEl() { return 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
*/
withUserChangeSource(fn) {
const previous = this.changeSource;
this.changeSource = 'user';
try {
return fn();
}
finally {
this.changeSource = previous;
}
}
/**
* 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
*/
onLeadingRowActivated() { }
/**
* 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
*/
focusInitial() {
const first = this.findNextEnabledIndex(0, 1, this.getVisibleItems());
if (first >= 0) {
this.setFocusedIndex(first);
}
}
/**
* 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
*/
setFocusedIndex(index) {
const max = this.getVisibleItems().length - 1;
const clamped = Math.max(-1, Math.min(max, index));
if (clamped === this.focusedIndex && !this.leadingRowFocused) {
return;
}
this.leadingRowFocused = false;
this.focusedIndex = clamped;
this.syncFocusedIndexToDom();
}
/**
* 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
*/
focusLeadingRow() {
if (!this.leadingRowEl) {
return false;
}
if (this.leadingRowFocused) {
return true;
}
this.leadingRowFocused = true;
this.focusedIndex = -1;
this.syncFocusedIndexToDom();
return true;
}
/**
* 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
*/
replaceLeadingRowElInDom() {
if (!this.opened || !this.leadingRowEl) {
return;
}
const next = this.createPopupListLeadingRowEl();
if (next === null) {
this.renderPopupList();
return;
}
const old = this.leadingRowEl;
old.replaceWith(next);
this.leadingRowEl = next;
if (this.focusedEl === old) {
next.classList.add(this.classIdMap.itemFocusedClass);
this.comboboxEl.setAttribute('aria-activedescendant', next.id);
this.focusedEl = next;
}
}
/**
* 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`).
*/
syncFocusedIndexToDom() {
if (this.focusedEl) {
this.focusedEl.classList.remove(this.classIdMap.itemFocusedClass);
this.focusedEl = undefined;
}
if (this.leadingRowFocused && this.leadingRowEl) {
this.leadingRowEl.classList.add(this.classIdMap.itemFocusedClass);
this.comboboxEl.setAttribute('aria-activedescendant', this.leadingRowEl.id);
this.focusedEl = this.leadingRowEl;
ensureVisibleInScroll(this.leadingRowEl, this.popupListEl);
return;
}
const i = this.focusedIndex;
if (i >= 0 && i < this.itemEls.length) {
const el = this.itemEls[i];
el.classList.add(this.classIdMap.itemFocusedClass);
this.comboboxEl.setAttribute('aria-activedescendant', el.id);
this.focusedEl = el;
ensureVisibleInScroll(el, this.popupListEl);
}
else {
this.comboboxEl.removeAttribute('aria-activedescendant');
}
}
/**
* 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.
*/
captureWindowScroll() {
const { scrollX, scrollY } = window;
const restore = () => {
if (window.scrollX !== scrollX || window.scrollY !== scrollY) {
window.scrollTo({ left: scrollX, top: scrollY, behavior: 'instant' });
}
};
return () => {
restore();
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(restore);
}
};
}
/**
* 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.
*/
eventTargetNode(ev) {
return ev.composedPath?.()?.[0] ?? ev.target;
}
attachOutsideClick() {
const mode = this.settings.outsideClickBehavior;
if (mode === 'pass-through') {
// mousedown fires before mouseup/click - feels snappier; we do not
// preventDefault, so the outside click still triggers its own action.
this.outsideHandler = (ev) => {
const t = this.eventTargetNode(ev);
if (t instanceof Node && !this.rootEl.contains(t)) {
this.close();
}
};
document.addEventListener('mousedown', this.outsideHandler);
}
else {
// 'block' needs TWO listeners because of an event-ordering race with
// the focusout-close path:
// mousedown outside -> browser shifts focus to the clicked target ->
// focusout fires on trigger -> focusOutHandler closes the popup ->
// detachOutsideClick removes the click capture below -> click then
// reaches the target and fires its own handler (regression).
// The mousedown capture below preventDefaults the focus shift on
// outside mousedowns, so no focusout fires and the click capture stays
// attached long enough to swallow the click.
this.blockMouseDownHandler = (ev) => {
const t = this.eventTargetNode(ev);
if (t instanceof Node && !this.rootEl.contains(t)) {
ev.preventDefault();
}
};
document.addEventListener('mousedown', this.blockMouseDownHandler, true);
// capture phase so we run before the target's own listeners; swallow
// the click so the underlying button/link/etc. does not fire.
this.outsideHandler = (ev) => {
const t = this.eventTargetNode(ev);
if (t instanceof Node && !this.rootEl.contains(t)) {
ev.stopPropagation();
ev.preventDefault();
this.close();
}
};
document.addEventListener('click', this.outsideHandler, true);
}
}
detachOutsideClick() {
if (this.outsideHandler) {
if (this.settings.outsideClickBehavior === 'pass-through') {
document.removeEventListener('mousedown', this.outsideHandler);
}
else {
document.removeEventListener('click', this.outsideHandler, true);
}
this.outsideHandler = undefined;
}
if (this.blockMouseDownHandler) {
document.removeEventListener('mousedown', this.blockMouseDownHandler, true);
this.blockMouseDownHandler = undefined;
}
}
/**
* 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.
*/
attachFocusOut() {
this.focusOutHandler = (ev) => {
const next = ev.relatedTarget;
if (next instanceof Node && this.rootEl.contains(next)) {
return;
}
this.close();
};
this.rootEl.addEventListener('focusout', this.focusOutHandler);
}
detachFocusOut() {
if (!this.focusOutHandler) {
return;
}
this.rootEl.removeEventListener('focusout', this.focusOutHandler);
this.focusOutHandler = undefined;
}
handleKeydown(ev) {
// Leave the keys to the IME while composing.
if (ev.isComposing || this.composing) {
return;
}
if (this.disabled) {
return;
}
const inText = ev.currentTarget === this.filterInputEl;
if (!inText && this.handleTypeaheadKeydown(ev)) {
return;
}
const action = getActionFromKey(ev, this.opened, inText);
if (action === undefined) {
return;
}
ev.preventDefault();
// Any action key ends the typed prefix (Enter/Space activate, Escape
// closes, arrows move on).
this.typeaheadBuffer = '';
switch (action) {
case LLSelectAction.Open:
this.open();
return;
case LLSelectAction.Close:
// Esc two-stage while the filter is active: clear the filter first; only
// close when the filter is already empty. Closing returns focus to
// the trigger.
if (this.filterActive && this.query !== '') {
this.filterInputEl.value = '';
this.handleSearchInputEvent();
return;
}
this.close();
return;
case LLSelectAction.Select: {
if (this.leadingRowFocused) {
this.withUserChangeSource(() => this.onLeadingRowActivated());
return;
}
const list = this.getVisibleItems();
if (this.focusedIndex >= 0 && this.focusedIndex < list.length) {
const item = list[this.focusedIndex];
// Defensive: nav never lands on a disabled item, but guard anyway.
if (!this.isItemEffectivelyDisabled(item)) {
this.withUserChangeSource(() => this.onItemActivated(item));
}
}
return;
}
case LLSelectAction.Next:
case LLSelectAction.Previous:
case LLSelectAction.GotoFirst:
case LLSelectAction.GotoLast:
case LLSelectAction.PageDown:
case LLSelectAction.PageUp: {
const list = this.getVisibleItems();
if (list.length === 0) {
return;
}
if (this.leadingRowFocused) {
// On the leading row (ring top): only downward actions move; treat
// the row as position -1 so Next lands on the first enabled item.
if (action === LLSelectAction.Next || action === LLSelectAction.PageDown || action === LLSelectAction.GotoLast) {
const target = getUpdatedIndex(-1, list.length - 1, action);
const found = this.findEnabledIndexForAction(target, action, list);
if (found >= 0) {
this.setFocusedIndex(found);
}
}
return;
}
// Home lands on the leading row when present (topmost of the ring).
if (action === LLSelectAction.GotoFirst && this.leadingRowEl) {
this.focusLeadingRow();
return;
}
const target = getUpdatedIndex(this.focusedIndex, list.length - 1, action);
const found = this.findEnabledIndexForAction(target, action, list);
// An up-action that cannot move (already at the topmost enabled item)
// continues onto the leading row.
const upAction = action === LLSelectAction.Previous || action === LLSelectAction.PageUp;
if (this.leadingRowEl && upAction && (found < 0 || found === this.focusedIndex)) {
this.focusLeadingRow();
return;
}
if (found >= 0) {
this.setFocusedIndex(found);
}
return;
}
}
}
/**
* 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.
*/
handleTypeaheadKeydown(ev) {
// Printable = exactly one code point ('Dead' / 'Process' fail, astral
// pairs pass). Meta and Ctrl-only chords are commands.
if (ev.key === ' ' || [...ev.key].length !== 1 || ev.metaKey || (ev.ctrlKey && !ev.altKey)) {
return false;
}
// Alt-carrying chords (AltGraph, macOS Option) PRODUCE characters and the
// produced character arrives as ev.key ("@", "a-ring", ...). A chord
// still delivering a bare ASCII letter / digit produced nothing - that is
// a shortcut (Windows Alt menus, accesskey, VoiceOver's Ctrl+Option), so
// it passes through.
if (ev.altKey && /^[a-zA-Z0-9]$/.test(ev.key)) {
return false;
}
if (this.opened ? this.filterActive : this.computeFilterActive()) {
return false;
}
ev.preventDefault();
const now = Date.now();
this.typeaheadBuffer = getUpdatedTypeaheadBuffer(this.typeaheadBuffer, ev.key, now - this.typeaheadLastTime);
this.typeaheadLastTime = now;
const openedByThisKey = !this.opened;
if (openedByThisKey) {
this.open();
// open() can refuse (hidden anchor): nothing to move focus in, and the
// buffer must not survive into a later successful open.
if (!this.opened) {
this.typeaheadBuffer = '';
return true;
}
}
const list = this.getVisibleItems();
const current = openedByThisKey
? this.computeTypeaheadClosedStartIndex(list)
: (this.leadingRowFocused ? -1 : this.focusedIndex);
const found = findTypeaheadIndex(this.typeaheadBuffer, list.length, current, (i) => this.isItemEffectivelyDisabled(list[i]) ? undefined : this.itemToString(list[i]));
if (found >= 0) {
this.setFocusedIndex(found);
}
return true;
}
/**
* 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
*/
computeTypeaheadClosedStartIndex(_list) {
return -1;
}
createTriggerEl() {
const el = document.createElement('div');
el.id = this.classIdMap.triggerId;
el.className = this.classIdMap.triggerClass;
// Filter active: trigger is a button that opens a popup containing a
// combobox+listbox. Inactive: trigger is itself the combobox.
el.setAttribute('role', this.filterActive ? 'button' : 'combobox');
el.setAttribute('tabindex', '0');
el.setAttribute('aria-controls', this.classIdMap.popupListId);
el.setAttribute('aria-expanded', 'false');
el.setAttribute('aria-haspopup', 'listbox');
el.setAttribute('data-state', 'closed');
el.setAttribute('data-disabled', 'false');
// Child slots: content (text/tags), optional clear button, arrow. Clear and
// arrow are own slots so they never collide with createTriggerContentElFn.
const content = document.createElement('span');
content.id = this.classIdMap.triggerContentId;
content.className = this.classIdMap.triggerContentClass;
el.append(content);
// The clear button is not built here: the first trigger render builds it
// (replaceTriggerClearButtonElInDom), so it is built once, not once here
// and again at that render.
const arrow = document.createElement('span');
arrow.className = this.classIdMap.triggerArrowClass;
el.append(arrow);
return el;
}
/**
* 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
*/
createTriggerClearButtonEl() {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = this.classIdMap.triggerClearButtonClass;
btn.tabIndex = -1;
btn.setAttribute('aria-label', this.settings.uiTranslationPack.triggerClearButtonAriaLabel);
const icon = this.createTriggerClearButtonContentEl();
if (icon !== null) {
btn.appendChild(icon);
}
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
// Disabled blocks every USER path to a value change (programmatic
// setters still work), and the clear button is
// reachable while closed - open() and keydown are already guarded.
if (this.isDisabled()) {
return;
}
this.withUserChangeSource(() => this.clearSelection());
});
return btn;
}
/**
* 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.
*/
focusedElementIn(el) {
const active = el.getRootNode().activeElement ?? null;
if (active === null || !el.contains(active)) {
return null;
}
// Descend through open shadow roots to the element that really holds
// focus: `activeElement` stops at a shadow host.
let deepest = active;
while (deepest.shadowRoot !== null && deepest.shadowRoot.activeElement !== null) {
deepest = deepest.shadowRoot.activeElement;
}
return deepest;
}
/** Whether `node` is `el` or inside it, walking up through open shadow hosts. */
containsComposed(el, node) {
let cursor = node;
while (cursor !== null) {
if (el.contains(cursor)) {
return true;
}
// Only a ShadowRoot (a DocumentFragment, nodeType 11) has a host worth
// climbing to. A detached element is its own root, and an `<a>` /
// `<area>` root carries a STRING `host` (its URL host) - never follow
// that. nodeType, not instanceof, so a foreign-realm root still counts.
const root = cursor.getRootNode();
cursor = root.nodeType === 11 ? root.host ?? null : null;
}
return false;
}
/** Whether DOM focus is on `el` or inside it (see `focusedElementIn`). */
isFocused(el) {
return this.focusedElementIn(el) !== null;
}
/**
* 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.
*/
replaceTriggerClearButtonElInDom() {
if (!this.settings.clearable) {
return;
}
const old = this.triggerClearButtonEl;
const focused = old === null ? null : this.focusedElementIn(old);
if (old !== null && focused !== null) {
old.focus({ preventScroll: true });
}
const next = this.createTriggerClearButtonEl();
this.triggerClearButtonEl = next;
if (old === null) {
this.triggerArrowEl.before(next);
return;
}
if (next === old) {
// Nothing was rebuilt: hand focus back to the node that held it - but
// only while it is still inside the button. The override may have
// detached it or moved it elsewhere; then focus stays on the button.
const holder = focused;
if (holder !== null && holder !== old && this.containsComposed(old, holder) && typeof holder.focus === 'function') {
holder.focus({ preventScroll: true });
}
return;
}
old.before(next);
if (focused !== null) {
next.focus({ preventScroll: true });
// An override may return a non-focusable element; then the old button
// still holds focus and removing it would drop focus to <body>. Keep it
// in the widget instead.
if (this.focusedElementIn(next) === null) {
this.triggerEl.focus({ preventScroll: true });
}
}
old.remove();
}
/**
* 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
*/
createTriggerClearButtonContentEl() {
return this.settings.createTriggerClearButtonContentElFn
? this.settings.createTriggerClearButtonContentElFn()
: 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
*/
clearSelection() { }
/**
* 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`.
*/
createFilterInputEl() {
const el = document.createElement('input');
el.type = 'text';
el.id = this.classIdMap.filterInputId;
el.className = this.classIdMap.filterInputClass;
el.setAttribute('role', 'combobox');
el.setAttribute('aria-controls', this.classIdMap.popupListId);
el.setAttribute('aria-expanded', 'false');
el.setAttribute('aria-autocomplete', 'list');
el.setAttribute('autocomplete', 'off');
el.setAttribute('autocapitalize', 'off');
el.setAttribute('spellcheck', 'false');
// Accessible name (aria-label / aria-labelledby) is owned by
// syncFieldNameToDom, which runs right after construction.
if (this.settings.uiTranslationPack.filterInputPlaceholder !== null) {
el.placeholder = this.settings.uiTranslationPack.filterInputPlaceholder;
}
return el;
}
/**
* 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() {
return this.filteredItems ?? this.getDisplayBaseItems();
}
/**
* 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`.
*/
getDisplayBaseItems() {
if (!this.settings.gatherGroups) {
return this.items;
}
if (this.gatheredItems === undefined) {
// Keys resolve via the protected overridable method itemToGroupKey, so
// an override drives the gather exactly like the render. All-null keys
// (grouping off) detect as contiguous and return `items` itself.
this.gatheredItems = gatherItemsByGroupKey(this.items, (item) => this.itemToGroupKey(item), this.settings.groupKeyCompareFn);
}
return this.gatheredItems;
}
/**
* 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() {
return this.query;
}
/**
* 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
*/
matchesQuery(item, query) {
const fn = this.settings.filterFn;
if (fn) {
return fn(item, query);
}
return this.itemToString(item).toLowerCase().includes(query.toLowerCase());
}
/**
* 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.
*/
recomputeFilteredItems() {
if (!this.filterActive) {
return;
}
const q = this.query;
// Filter over the display base (gathered order): filtering preserves
// contiguity, so a group's position cannot jump while typing.
const base = this.getDisplayBaseItems();
this.filteredItems = q === '' ? base.slice() : base.filter(it => this.matchesQuery(it, q));
}
/**
* 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.
*/
handleSearchInputEvent() {
if (this.composing) {
return;
}
this.query = this.filterInputEl.value;
this.recomputeFilteredItems();
this.focusedIndex = -1;
this.renderPopupList();
// First ENABLED match, not blindly index 0: disabled items are skipped
// by keyboard navigation (A11Y.md "Disabled"), and the active option a
// keystroke lands on is keyboard state.
this.setFocusedIndex(this.findNextEnabledIndex(0, 1, this.getVisibleItems()));
}
/** Outer popup wrapper. No ARIA role; structural only. */
createPopupEl() {
const el = document.createElement('div');
el.className = this.classIdMap.popupClass;
return el;
}
/**
* 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`.
*/
createPopupListNoResultsEl() {
const el = document.createElement('div');
el.className = this.classIdMap.popupListNoResultsClass;
el.setAttribute('role', 'status');
el.hidden = true;
return el;
}
/**
* 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
*/
createPopupListNoResultsContentEl(query) {
return this.settings.createPopupListNoResultsContentElFn
? this.settings.createPopupListNoResultsContentElFn(query)
: 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`).
*/
syncPopupListNoResultsToDom() {
const empty = this.getVisibleItems().length === 0;
this.popupListNoResultsEl.hidden = !empty;
if (!empty) {
this.lastNoResultsText = null;
return;
}
const content = this.createPopupListNoResultsContentEl(this.query);
const nextText = content === null
? this.settings.uiTranslationPack.popupListNoResults
: content.textContent ?? '';
if (nextText === this.lastNoResultsText) {
return;
}
this.lastNoResultsText = nextText;
if (content === null) {
this.popupListNoResultsEl.textContent = this.settings.uiTranslationPack.popupListNoResults;
}
else {
this.popupListNoResultsEl.replaceChildren(content);
}
}
/** Inner element with `role="listbox"`. Holds item children. */
createPopupListEl() {
const el = document.createElement('div');
el.id = this.classIdMap.popupListId;
el.className = this.classIdMap.popupListClass;
el.setAttribute('role', 'listbox');
el.setAttribute('tabindex', '-1');
return el;
}
}
/**
* Single-selection select. Picking an item replaces any prior chosen item
* and closes the popup. Use `setChosenItem(undefined)` to clear the selection.
*
* @typeParam T - item type. Supply your own `compareFn` for non-primitive `T`.
* @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
*/
class LLSelectSingle extends LLSelectBase {
constructor(targetEl, settings, subclassSettings) {
const ownExtras = {
onChange: settings?.onChange ?? null,
createTriggerContentElFn: settings?.createTriggerContentElFn ?? null,
};
// Cast mirrored from the base constructor: TS cannot prove "own extras +
// Omit<S, own keys>" reassembles a generic S's extras. Own extras stay
// satisfies-checked above; incoming extras are param-typed.
super(targetEl, settings, { ...ownExtras, ...subclassSettings });
/**
* Currently chosen item, or `undefined` if none.
* @group State (protected)
*/
this.chosenItem = undefined;
this.renderTrigger();
}
/**
* Return the currently chosen item, or `undefined` if none.
* @group Selection
*/
getChosenItem() {
return this.chosenItem;
}
/**
* Set the chosen item programmatically.
* - `undefined` clears the choice.
* - Fires `onChange` only when the item actually differs from the current
* one (compared via `compareFn`).
* - Accepts an item that is not (yet) in the items list, for async data
* flows. If a later `setItems` does not include it, it is dropped
* automatically.
* - It does not check disabled state: a disabled item can be chosen
* programmatically. Native `<select>` behaves the same.
* @group Selection
*/
setChosenItem(item) {
if (this.areEqual(item, this.chosenItem)) {
return;
}
const previous = this.chosenItem;
this.chosenItem = item;
this.renderTrigger();
// Popup open: refresh only the two affected options so their
// aria-selected stays true to state (O(1); no-op while closed).
if (previous !== undefined) {
this.replacePopupListItemElInDom(previous);
}
if (item !== undefined) {
this.replacePopupListItemElInDom(item);
}
this.fireChange(previous);
}
/**
* Orchestrator: composes `syncEmptyStateToDom` + `commitTriggerContentToDom`
* to (re)build the trigger from state; touches no DOM directly.
* - `createTriggerContentElFn` is tried first; if it returns `null` or is
* unset, the default applies.
* - The default is the chosen item's string, or the placeholder when
* nothing is chosen.
* @group Subclassing: rendering
*/
renderTriggerContent() {
this.syncEmptyStateToDom();
const plainValue = this.chosenItem === undefined ? this.settings.placeholder : this.itemToString(this.chosenItem);
const custom = this.settings.createTriggerContentElFn?.({ chosenItem: this.chosenItem, items: this.getItems() }) ?? null;
if (custom !== null) {
this.commitTriggerContentToDom(custom, plainValue);
return;
}
this.commitTriggerContentToDom(plainValue);
}
/**
* No selection iff `chosenItem` is unset. Drives the trigger's `data-empty`.
* @group Subclassing: semantics
*/
isEmpty() {
return this.chosenItem === undefined;
}
/**
* Mark the chosen option `aria-selected="true"`, the rest `"false"` (APG select-only).
* @group Subclassing: rendering
*/
createItemEl(item, index) {
const el = super.createItemEl(item, index);
el.setAttribute('aria-selected', String(this.areEqual(item, this.chosenItem)));
return el;
}
/**
* Pick this item as the chosen item and close the popup.
* @group Subclassing: reactions
*/
onItemActivated(item) {
this.setChosenItem(item);
this.close();
}
/**
* Clear button empties the single selection to `undefined`.
* @group Subclassing: semantics
*/
clearSelection() {
this.setChosenItem(undefined);
}
/**
* On open, focus the chosen item (if present and enabled), else the first
* enabled item. Indices are into `getVisibleItems()` (the rendered list).
* @group Subclassing: focus
*/
focusInitial() {
const list = this.getVisibleItems();
const c = this.chosenItem;
if (c !== undefined) {
const idx = list.findIndex(o => this.settings.compareFn(o, c));
if (idx >= 0 && !this.isItemEffectivelyDisabled(list[idx])) {
this.setFocusedIndex(idx);
return;
}
}
const first = this.findNextEnabledIndex(0, 1, list);
if (first >= 0) {
this.setFocusedIndex(first);
}
}
/**
* Closed-state typeahead searches relative to the CHOSEN item, like a
* native `<select>`: typing its initial cycles to the next match.
* - Returns `-1` when nothing is chosen, or the chosen item left the list;
* the search then starts from the top.
* @group Subclassing: focus
*/
computeTypeaheadClosedStartIndex(list) {
const c = this.chosenItem;
if (c === undefined) {
return -1;
}
return list.findIndex((o) => this.settings.compareFn(o, c));
}
/**
* Re-match the chosen item against the new list after `setItems`.
* - If the list no longer holds it (by `compareFn`), it is dropped and
* `onChange` fires.
* - If 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. The logical value did not change, so `onChange`
* does not fire.
* - The trigger content re-renders after every `setItems`, because a custom
* `createTriggerContentElFn` receives `items`.
* - The arrow re-renders only when the chosen item is dropped, because that
* runs the whole trigger.
* @group Subclassing: reactions
*/
onItemsChanged() {
const previous = this.chosenItem;
if (previous !== undefined) {
const idx = this.items.findIndex(o => this.settings.compareFn(o, previous));
if (idx < 0) {
this.chosenItem = undefined;
this.renderTrigger();
this.fireChange(previous);
return;
}
const matched = this.items[idx];
// Reference swap = a different VALUE under SameValueZero, so a NaN item
// matching itself is not a swap.
if (!defaultCompareFn(matched, previous)) {
this.chosenItem = matched;
}
}
// A custom trigger receives `items`, so every list change refreshes the
// content; the arrow does not depend on the list.
this.renderTriggerContent();
}
areEqual(a, b) {
if (a === undefined && b === undefined) {
return true;
}
if (a === undefined || b === undefined) {
return false;
}
return this.settings.compareFn(a, b);
}
fireChange(previousChosenItem) {
// Consume the source before any observer runs: a programmatic setter
// called from inside onChange (or a subclass reaction) must report
// 'api', not inherit the outer interaction's 'user' attribution.
const meta = { source: this.changeSource };
this.changeSource = 'api';
this.onChosenChanged();
this.settings.onChange?.(this.chosenItem, previousChosenItem, meta);
}
}
/**
* 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
*/
class LLSelectMultiple extends LLSelectBase {
constructor(targetEl, settings, subclassSettings) {
const ownExtras = {
onChange: settings?.onChange ?? null,
createTriggerContentElFn: settings?.createTriggerContentElFn ?? null,
triggerDisplay: settings?.triggerDisplay ?? 'count',
createTagContentElFn: settings?.createTagContentElFn ?? null,
createTagRemoveButtonContentElFn: settings?.createTagRemoveButtonContentElFn ?? null,
hideChosenRows: settings?.hideChosenRows ?? false,
chooseAllRow: settings?.chooseAllRow ?? false,
createChooseAllRowContentElFn: settings?.createChooseAllRowContentElFn ?? null,
};
// Cast mirrored from the base constructor: TS cannot prove "own extras +
// Omit<S, own keys>" reassembles a generic S's extras. Own extras stay
// satisfies-checked above; incoming extras are param-typed.
super(targetEl, settings, { ...ownExtras, ...subclassSettings });
/**
* Currently chosen items, in insertion order.
* @group State (protected)
*/
// `readonly` so a subclass cannot `push`/`splice` it: every mutation must
// REPLACE the array (the invariant both `chosenSetCache` and `visibleItemsCache`
// rely on - a reference change is how they invalidate).
this.chosenItems = [];
this.chosenSetCache = null;
/**
* 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.
*/
this.visibleItemsCache = null;
this.popupListEl.setAttribute('aria-multiselectable', 'true');
this.renderTrigger();
}
/**
* Return the currently chosen items (insertion order).
* @group Selection
*/
getChosenItems() {
return this.chosenItems;
}
/**
* 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) {
// Set semantics, as compareFn's contract promises. The choose* bulk ops
// filter through isChosen and never produce duplicates; this setter is
// the one door raw arrays (framework model write-back included) come in
// through, so the dedup lives here.
let next;
if (this.settings.compareFn === defaultCompareFn) {
next = [...new Set(items)];
}
else {
next = [];
for (const item of items) {
if (!next.some(c => this.settings.compareFn(c, item))) {
next.push(item);
}
}
}
if (this.arraysEqual(next, this.chosenItems)) {
return;
}
const previous = this.chosenItems;
this.chosenItems = next;
this.rerender();
this.fireChange(previous);
}
/**
* Whether the given item is currently chosen (via `compareFn`).
* @group Selection
*/
isChosen(item) {
// Default (identity) compareFn: O(1) Set membership instead of a linear scan,
// so a multi popup render is O(visible), not O(visible x chosen). The Set is
// memoized and invalidated by the chosenItems array reference - every mutation
// replaces the array, never mutates in place (mirrors visibleItemsCache). A
// custom compareFn cannot hash, so it stays linear. The Set is SameValueZero,
// and so is defaultCompareFn, so NaN behaves the same on both paths.
if (this.settings.compareFn === defaultCompareFn) {
return this.chosenSet().has(item);
}
return this.chosenItems.some(c => this.settings.compareFn(c, item));
}
/** Memoized Set of `chosenItems` for the default-compareFn `isChosen` fast path. */
chosenSet() {
const cache = this.chosenSetCache;
if (cache !== null && cache.chosen === this.chosenItems) {
return cache.set;
}
const set = new Set(this.chosenItems);
this.chosenSetCache = { chosen: this.chosenItems, set };
return set;
}
/**
* 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) {
const previous = this.chosenItems;
const idx = previous.findIndex(c => this.settings.compareFn(c, item));
if (idx >= 0) {
this.chosenItems = [...previous.slice(0, idx), ...previous.slice(idx + 1)];
}
else {
this.chosenItems = [...previous, item];
}
// Only one item's selection changed, so the popup list replaces just that
// one row (plus the choose-all tri-state) instead of rebuilding every row -
// O(1) in list size. Exception: hideChosenRows moves the row in or out of
// the list and shifts the indexes, so it rebuilds the list instead. The
// trigger is refreshed too; its cost depends on triggerDisplay (count =
// constant, tags = one chip per chosen item, custom = caller-defined), so
// the whole update is not unconditionally O(1).
this.renderTrigger();
if (this.settings.hideChosenRows) {
if (this.isOpened()) {
this.renderPopupList();
}
}
else {
this.replacePopupListItemElInDom(item);
this.replaceLeadingRowElInDom();
}
this.fireChange(previous);
}
/**
* 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() {
this.setChosenItems(this.items.filter(it => !this.isItemEffectivelyDisabled(it) || this.isChosen(it)));
}
/**
* 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() {
this.setChosenItems(this.chosenItems.filter(c => this.isItemEffectivelyDisabled(c)));
}
/**
* 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() {
const enabled = this.items.filter(it => !this.isItemEffectivelyDisabled(it));
const allChosen = enabled.length > 0 && enabled.every(it => this.isChosen(it));
if (allChosen) {
this.unchooseAll();
}
else {
this.chooseAll();
}
}
/**
* 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() {
const actionable = this.getVisibleEnabledItems();
if (actionable.length === 0) {
return;
}
const allChosen = actionable.every(i => this.isChosen(i));
if (allChosen) {
this.setChosenItems(this.chosenItems.filter(c => !actionable.some(v => this.settings.compareFn(v, c))));
}
else {
const additions = actionable.filter(v => !this.isChosen(v));
this.setChosenItems([...this.chosenItems, ...additions]);
}
}
/**
* 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
*/
getVisibleEnabledItems() {
return this.getVisibleItems().filter(i => !this.isItemEffectivelyDisabled(i));
}
/**
* 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() {
const list = super.getVisibleItems();
if (!this.settings.hideChosenRows || this.chosenItems.length === 0) {
return list;
}
const cache = this.visibleItemsCache;
if (cache !== null && cache.base === list && cache.chosen === this.chosenItems) {
return cache.result;
}
let result;
if (this.settings.compareFn === defaultCompareFn) {
const chosen = new Set(this.chosenItems);
result = list.filter(it => !chosen.has(it));
}
else {
result = list.filter(it => !this.isChosen(it));
}
this.visibleItemsCache = { base: list, chosen: this.chosenItems, result };
return result;
}
/**
* 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
*/
renderTriggerContent() {
this.syncEmptyStateToDom();
const chosenCount = this.chosenItems.length;
const countValue = chosenCount === 0
? this.settings.placeholder
: this.settings.uiTranslationPack.triggerCountSummary(chosenCount, this.items.length);
const custom = this.settings.createTriggerContentElFn?.({ chosenItems: this.getChosenItems(), items: this.getItems() }) ?? null;
if (custom !== null) {
this.commitTriggerContentToDom(custom, countValue);
return;
}
if (this.settings.triggerDisplay === 'tags' && chosenCount > 0) {
// Accessible value = the item texts themselves; the chips (with their
// labelled remove buttons) must not name the field.
this.commitTriggerContentToDom(this.createTagsEl(), this.chosenItems.map((item) => this.itemToString(item)).join(', '));
return;
}
this.commitTriggerContentToDom(countValue);
}
/**
* 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
*/
createTagsEl() {
const wrap = document.createElement('span');
wrap.className = this.classIdMap.tagsClass;
for (const item of this.chosenItems) {
wrap.appendChild(this.createTagEl(item));
}
return wrap;
}
/**
* 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
*/
createTagEl(item) {
const tag = document.createElement('span');
tag.className = this.classIdMap.tagClass;
// Same null-branch shape as createItemEl / createGroupEl: null = plain-text
// default. Text must be set before the remove button (textContent wipes children).
const content = this.createTagContentEl(item);
if (content === null) {
tag.textContent = this.itemToString(item);
}
else {
tag.appendChild(content);
}
if (this.isItemEffectivelyDisabled(item)) {
// Mirror createItemEl's disabled marking: aria-disabled + the class grey the
// chip so its inert x reads as disabled, not broken (A11Y.md Tags).
tag.setAttribute('aria-disabled', 'true');
tag.classList.add(this.classIdMap.tagDisabledClass);
}
tag.appendChild(this.createTagRemoveButtonEl(item));
return tag;
}
/**
* 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
*/
createTagRemoveButtonEl(item) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = this.classIdMap.tagRemoveButtonClass;
btn.tabIndex = -1;
btn.setAttribute('aria-label', this.itemToTagRemoveButtonAriaLabel(item));
if (this.isItemEffectivelyDisabled(item)) {
btn.setAttribute('aria-disabled', 'true');
}
const icon = this.createTagRemoveButtonContentEl(item);
if (icon !== null) {
btn.appendChild(icon);
}
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
// Tag chips sit in the trigger, reachable while the whole control or the
// item itself is disabled. Both block removal (A11Y.md: only user
// re-toggling is blocked; the item stays chosen). toggleItem stays
// disabled-blind so the programmatic channel matches native <select>.
if (this.isDisabled()) {
return;
}
if (this.isItemEffectivelyDisabled(item)) {
return;
}
this.withUserChangeSource(() => this.toggleItem(item));
});
return btn;
}
/**
* 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
*/
createTagRemoveButtonContentEl(item) {
return this.settings.createTagRemoveButtonContentElFn
? this.settings.createTagRemoveButtonContentElFn(item)
: 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
*/
createTagContentEl(item) {
return this.settings.createTagContentElFn ? this.settings.createTagContentElFn(item) : 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
*/
itemToTagRemoveButtonAriaLabel(item) {
return this.settings.uiTranslationPack.tagRemoveButtonAriaLabel(this.itemToString(item));
}
/**
* No selection iff the chosen set is empty. Drives the trigger's `data-empty`.
* @group Subclassing: semantics
*/
isEmpty() {
return this.chosenItems.length === 0;
}
/**
* Toggle on click. Multi mode keeps the popup open.
* @group Subclassing: reactions
*/
onItemActivated(item) {
this.toggleItem(item);
}
/**
* Clear button empties the chosen-items set to `[]`.
* @group Subclassing: semantics
*/
clearSelection() {
this.setChosenItems([]);
}
/**
* 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
*/
createPopupListLeadingRowEl() {
if (!this.settings.chooseAllRow) {
return null;
}
const actionable = this.getVisibleEnabledItems();
if (actionable.length === 0) {
return null;
}
const chosenCount = actionable.filter(i => this.isChosen(i)).length;
const state = chosenCount === 0 ? 'none' : chosenCount === actionable.length ? 'all' : 'some';
const el = document.createElement('div');
el.id = `${this.classIdMap.popupListId}-choose-all`;
el.className = `${this.classIdMap.itemClass} ${this.classIdMap.chooseAllRowClass}`;
el.setAttribute('role', 'option');
el.setAttribute('data-chosen-state', state);
// ARIA option has no `mixed`: the indeterminate state is conveyed by the
// visual (data-chosen-state) + the counting accessible name only.
el.setAttribute('aria-selected', String(state === 'all'));
const text = this.settings.uiTranslationPack.chooseAllRowText(chosenCount, actionable.length);
const content = this.createChooseAllRowContentEl(state, chosenCount, actionable.length);
if (content === null) {
// Default content: just the counting text - its numbers already carry
// the tri-state, and the library ships no default indicator anywhere
// (DESIGN.md "Choose-all default: plain counting text").
el.textContent = text;
}
else {
// Custom content fills the visuals only; the accessible name stays the
// counting text (same pinning as createItemEl's custom content).
el.setAttribute('aria-label', text);
el.appendChild(content);
}
el.addEventListener('click', () => {
// Focus-then-activate, mirroring the item click wiring.
this.focusLeadingRow();
this.withUserChangeSource(() => this.onLeadingRowActivated());
});
return el;
}
/**
* 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
*/
createChooseAllRowContentEl(chosenState, chosenCount, totalCount) {
return this.settings.createChooseAllRowContentElFn
? this.settings.createChooseAllRowContentElFn(chosenState, chosenCount, totalCount)
: null;
}
/**
* Activate the choose-all row: delegates to {@link toggleAllVisible}.
* @group Subclassing: reactions
*/
onLeadingRowActivated() {
this.toggleAllVisible();
}
/**
* Mark each item with `aria-selected` reflecting its chosen state.
* @group Subclassing: rendering
*/
createItemEl(item, index) {
const el = super.createItemEl(item, index);
el.setAttribute('aria-selected', String(this.isChosen(item)));
return el;
}
/**
* 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
*/
onItemsChanged() {
const previous = this.chosenItems;
let swapped = false;
const nextChosen = [];
for (const c of previous) {
const idx = this.items.findIndex(item => this.settings.compareFn(item, c));
if (idx < 0) {
continue;
}
const matched = this.items[idx];
// SameValueZero, so a NaN item matching itself is not a swap.
if (!defaultCompareFn(matched, c)) {
swapped = true;
}
nextChosen.push(matched);
}
if (nextChosen.length !== previous.length) {
this.chosenItems = nextChosen;
this.renderTrigger();
this.fireChange(previous);
return;
}
if (swapped) {
this.chosenItems = nextChosen;
}
// The count summary reads the list total and a custom trigger receives
// `items`, so every list change refreshes the content; the arrow does not
// depend on the list.
this.renderTriggerContent();
}
/**
* 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
*/
focusInitial() {
const list = this.getVisibleItems();
const firstChosen = this.chosenItems[0];
if (firstChosen !== undefined) {
const idx = list.findIndex(i => this.settings.compareFn(i, firstChosen));
if (idx >= 0 && !this.isItemEffectivelyDisabled(list[idx])) {
this.setFocusedIndex(idx);
return;
}
}
if (this.focusLeadingRow()) {
return;
}
const first = this.findNextEnabledIndex(0, 1, list);
if (first >= 0) {
this.setFocusedIndex(first);
}
}
arraysEqual(a, b) {
if (a.length !== b.length) {
return false;
}
const eq = this.settings.compareFn;
for (let i = 0; i < a.length; i++) {
if (!eq(a[i], b[i])) {
return false;
}
}
return true;
}
fireChange(previousChosenItems) {
// Consume the source before any observer runs: a programmatic setter
// called from inside onChange (or a subclass reaction) must report
// 'api', not inherit the outer interaction's 'user' attribution.
const meta = { source: this.changeSource };
this.changeSource = 'api';
this.onChosenChanged();
this.settings.onChange?.(this.chosenItems, previousChosenItems, meta);
}
}
// Query-match highlighting for item content, exported like the grouping
// gather: pure (text, query) -> detached DOM, no instance state.
/**
* Build a detached `<span>` of `text` with every `query` match wrapped for
* highlighting.
* - Each case-insensitive occurrence of `query` becomes a `<mark>` element;
* everything else stays plain text nodes.
* - Matching mirrors the built-in filter exactly: `toLowerCase` on both
* sides, no trimming, scanned left to right without overlap.
* - If `query` is `''`, the span holds the plain text and no `<mark>`.
* - `createMatchElFn` replaces the default `<mark>` builder. It receives the
* matched text and must return a fully built element: the helper inserts
* it as-is and does not put the text inside for you.
* - If a custom `filterFn` drives your matching, the helper cannot know its
* match ranges: it always marks plain substring occurrences.
* - Intended for `createItemContentElFn` / `createItemContentEl`: they re-run
* on every filter keystroke, so the marks stay in sync with the query. The
* option's accessible name is unaffected (it comes from `itemToString`).
* - Rare edge: if lower-casing changes the string's length (a Unicode
* expansion, e.g. dotted capital I, U+0130), the span degrades to plain
* unmarked text instead of marking wrong ranges.
* @group Filtering
*/
function createHighlightedTextEl(text, query, createMatchElFn) {
const span = document.createElement('span');
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
// Offsets found in lowerText are applied to text; valid only while
// lower-casing kept both lengths (lowercase mappings only ever expand).
if (query === '' || lowerText.length !== text.length || lowerQuery.length !== query.length) {
span.textContent = text;
return span;
}
let pos = 0;
while (true) {
const idx = lowerText.indexOf(lowerQuery, pos);
if (idx === -1) {
break;
}
if (idx > pos) {
span.append(text.slice(pos, idx));
}
const matchedText = text.slice(idx, idx + query.length);
if (createMatchElFn) {
span.append(createMatchElFn(matchedText));
}
else {
const mark = document.createElement('mark');
mark.textContent = matchedText;
span.append(mark);
}
pos = idx + query.length;
}
if (pos < text.length) {
span.append(text.slice(pos));
}
return span;
}
// Opt-in icon helpers. None of these is used by the library by default; pass
// the arrow ones via `settings.createTriggerArrowContentElFn`, or use the checkmark / checkbox
// ones inside a custom item renderer (override `createItemEl` / `itemToString`)
// so people who do not want to pull in mdi / FontAwesome still get sensible
// built-ins. All paths use fill="currentColor" so they inherit the
// surrounding text color (light/dark themes "just work"). Paths are from
// Material Design Icons (MIT).
const SVG_NS = 'http://www.w3.org/2000/svg';
function createSvgEl(viewBox, pathD, size) {
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
svg.setAttribute('viewBox', viewBox);
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', pathD);
path.setAttribute('fill', 'currentColor');
svg.appendChild(path);
return svg;
}
/**
* Solid filled triangle pointing down. Sized to roughly match the chevron's
* visual weight (MDI's `arrow_drop_down` path occupies a small portion of
* its 24x24 viewBox and looks too small next to other icons).
* @group Icons
* @category Arrows
*/
function createTriangleDownSvgEl(opts = {}) {
return createSvgEl('0 0 24 24', 'M4 8l8 10 8-10z', opts.size ?? 16);
}
/**
* Material Design `expand_more` chevron pointing down (filled outline).
* @group Icons
* @category Arrows
*/
function createChevronDownSvgEl(opts = {}) {
return createSvgEl('0 0 24 24', 'M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z', opts.size ?? 16);
}
/**
* Standalone checkmark (no box). Useful as a "selected" indicator in single
* mode, or as a lightweight chosen marker in multi mode.
* @group Icons
* @category Checkmarks & checkboxes
*/
function createCheckmarkSvgEl(opts = {}) {
return createSvgEl('0 0 24 24', 'M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z', opts.size ?? 16);
}
const OUTLINED_CHECKBOX_PATHS = {
// mdi checkbox-blank-outline
unchecked: 'M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z',
// mdi checkbox-outline (box + tick)
checked: 'M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M19,5V19H5V5H19M10,17L6,13L7.41,11.58L10,14.17L16.59,7.58L18,9',
// mdi minus-box-outline (box + dash)
indeterminate: 'M19,19V5H5V19H19M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5C3,3.89 3.9,3 5,3H19M17,11V13H7V11H17Z',
};
/** Map the choose-all row's chosen-state vocabulary onto the icon vocabulary. */
function resolveCheckboxState(raw) {
return raw === 'none' ? 'unchecked' : raw === 'some' ? 'indeterminate' : raw === 'all' ? 'checked' : raw;
}
/**
* Outlined checkbox icon: box border with the tick (`checked`) / dash
* (`indeterminate`) drawn inside, all in `currentColor`; the filled twin is
* {@link createFilledCheckboxSvgEl}. Intended for multi-select item rows and
* the choose-all control. Decorative only (`aria-hidden`); the real state is
* carried by `aria-selected` on the item or `aria-checked` on the control.
* @group Icons
* @category Checkmarks & checkboxes
*/
function createOutlinedCheckboxSvgEl(opts = {}) {
const state = resolveCheckboxState(opts.state ?? 'unchecked');
return createSvgEl('0 0 24 24', OUTLINED_CHECKBOX_PATHS[state], opts.size ?? 16);
}
const FILLED_CHECKBOX_PATHS = {
// Material's unchecked is the same outline box in both looks.
unchecked: OUTLINED_CHECKBOX_PATHS.unchecked,
// mdi checkbox-marked (solid box, tick cut out)
checked: 'M10,17L5,12L6.41,10.58L10,14.17L17.59,6.58L19,8M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z',
// mdi minus-box (solid box, dash cut out)
indeterminate: 'M17,13H7V11H17M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z',
};
/**
* Material-look checkbox icon: a solid rounded box with the tick (`checked`)
* / dash (`indeterminate`) cut out; `unchecked` draws the same outline box as
* {@link createOutlinedCheckboxSvgEl}. Same options, including the chosen-state
* vocabulary. Decorative only (`aria-hidden`); the real state is carried by
* `aria-selected` on the item or `aria-checked` on the control.
* @group Icons
* @category Checkmarks & checkboxes
*/
function createFilledCheckboxSvgEl(opts = {}) {
const state = resolveCheckboxState(opts.state ?? 'unchecked');
return createSvgEl('0 0 24 24', FILLED_CHECKBOX_PATHS[state], opts.size ?? 16);
}
/**
* The package entry: the two select classes, their settings types, and the
* SVG icon builders. Language packs live in `@llselect/core/i18n`.
* @module @llselect/core
*/
/**
* Library version. Mirrors package.json `version` (smoke-test guarded).
* @group Metadata
*/
const version = '0.0.8';
export { LLSelectBase, LLSelectMultiple, LLSelectSingle, createCheckmarkSvgEl, createChevronDownSvgEl, createFilledCheckboxSvgEl, createHighlightedTextEl, createOutlinedCheckboxSvgEl, createTriangleDownSvgEl, gatherItemsByGroupKey, version };
//# sourceMappingURL=index.mjs.map