UNPKG

@llselect/core

Version:

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

3,754 lines 175 kB
'use strict';

// 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';

exports.LLSelectBase = LLSelectBase;
exports.LLSelectMultiple = LLSelectMultiple;
exports.LLSelectSingle = LLSelectSingle;
exports.createCheckmarkSvgEl = createCheckmarkSvgEl;
exports.createChevronDownSvgEl = createChevronDownSvgEl;
exports.createFilledCheckboxSvgEl = createFilledCheckboxSvgEl;
exports.createHighlightedTextEl = createHighlightedTextEl;
exports.createOutlinedCheckboxSvgEl = createOutlinedCheckboxSvgEl;
exports.createTriangleDownSvgEl = createTriangleDownSvgEl;
exports.gatherItemsByGroupKey = gatherItemsByGroupKey;
exports.version = version;
//# sourceMappingURL=index.cjs.map