Appearance
Listbox — live demo
This is the real @rozie-ui/listbox-vue package running on this page (VitePress is itself a Vue app). Open the select with the keyboard or mouse, type to filter the combobox, toggle options in the multi-select — then watch the two-way bound value update. Everything below is driven by the same Listbox.rozie source that compiles to all six frameworks, with no engine and no required CSS — the ARIA behaviour and a tokenised skin ship inside the component.
value is two-way bound with v-model:value — the readout updates the instant you commit a selection, and a consumer write flows back in. The Select instance's buttons drive the imperative handle (open(), clear()) grabbed through Vue's ref. Flip combobox for a filterable text input, add multiple for array values — the same component, the same surface. See the full API for every prop, event, slot, and handle verb, plus theming and keyboard reference.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
Listbox.rozie — a headless, WAI-ARIA select-only Listbox.
The first @rozie-ui family with NO third-party vanilla engine: every line of
behaviour — roving virtual focus (aria-activedescendant), full keyboard
navigation, type-ahead, single + multi select — is authored in Rozie itself.
(Type-to-filter is owned by the sibling @rozie-ui/combobox family — P3/D-03
retired Listbox's editable-input mode; both families share the
@rozie-ui/headless-core/listCore.rzts spine.) It exists to stress the *native*
author-side primitives the engine-wrapper families never exercise on their own:
- $computed-derived state → the filtered + active-resolved option list
- parameterized @keydown modifiers → arrow/home/end/enter/escape navigation
- $refs-driven focus management → input/listbox/option focus, read post-mount only
- two-way r-model:value → selection is a controlled form value (Angular CVA)
- scoped slots → #option / #selected / #empty render props
- $expose imperative handle → open / close / toggle / clear / focusControl
It follows the ARIA APG "Select-Only Combobox" pattern (the trigger keeps
role="combobox"): DOM focus stays on the control, the highlighted option is
tracked virtually via `aria-activedescendant`, and selection commits a fresh value.
Authoring notes (collision classes a no-engine component is the first to hit):
- `$data.open` collides with the `open` $expose verb — Phase 46 ITEM-5 now
auto-renames the INTERNAL state to `open$local` (cross-target; the exposed
`open()` verb stays). This file dogfoods that fix by using the natural
`$data.open` instead of the old `$data.expanded` workaround.
- A local helper named `valueOf` (an Object.prototype member) collides with
the inherited member when it becomes a Lit/Angular class field — Phase 46
ITEM-5 now auto-renames it to `valueOf$local`. This file dogfoods that fix
by using the natural `valueOf` instead of the old `valueOf` workaround.
- The focus verb is `focusControl`, not `focus` — a `focus` $expose verb is a
DELIBERATE override of the inherited HTMLElement.focus on the Lit custom
element (ROZ137 warns, does not auto-rename — the public handle is intended).
- Every event is fired through ONE wrapper fn so its prop-destructure hoists
once (a duplicate-emit-site would emit two `const … = props` in React).
Consumer example (select-only):
<Listbox r-model:value="$data.fruit" :options="$data.fruits" placeholder="Pick a fruit…">
<template #option="{ option, active, selected }">
<span :class="{ active, selected }">{{ option.label }}</span>
</template>
</Listbox>
-->
<rozie name="Listbox" vendorable="true">
<props>
{
// The option set. Each entry is either a primitive (string/number) or an
// object; objects resolve their label/value/disabled via the option* props
// (falling back to `.label` / `.value` / `.disabled`).
options: {
type: Array,
default: () => [],
docs: {
description:
'The option set. Each entry is either a primitive (`string`/`number`) or an object; objects resolve their label, value, and disabled state via the `option*` resolver props, falling back to `.label` / `.value` / `.disabled`.',
},
},
// The selected value (two-way). Scalar in single-select; an array of values
// in multi-select. As the sole `model:true` prop it drives the Angular CVA —
// a Listbox IS a form control.
value: {
type: null,
default: null,
model: true,
docs: {
description:
'The selected value (two-way `r-model`) — a scalar in single-select, an array of values in multi-select. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Listbox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).',
example: '<Listbox r-model:value="fruit" :options="fruits" />',
},
},
// Multi-select: `value` becomes an array; selecting toggles membership and
// keeps the popup open.
multiple: {
type: Boolean,
default: false,
docs: {
description:
'Enable multi-select: `value` becomes an array, selecting an option toggles its membership, and the popup stays open after each commit.',
},
},
// Render the results list IN FLOW (static) instead of as an absolute popup.
// Use when the listbox is embedded inside a fixed-size, overflow-clipped
// container (e.g. a command-palette panel) where an absolute popup would be
// culled. Off (default) keeps the standalone dropdown behavior unchanged.
inline: {
type: Boolean,
default: false,
docs: {
description:
'Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the listbox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).',
},
},
disabled: {
type: Boolean,
default: false,
docs: {
description:
'Disable the control entirely. Also sets the Angular `ControlValueAccessor` disabled state.',
},
},
placeholder: {
type: String,
default: '',
docs: {
description: 'Placeholder text shown in the empty control.',
},
},
// Close the popup after a selection. Defaults true; multi-select callers
// usually want it open — pass :closeOnSelect="false".
closeOnSelect: {
type: Boolean,
default: true,
docs: {
description:
'Close the popup after a single-select commit. Defaults `true`; multi-select keeps the popup open regardless of this setting.',
},
},
// Resolver overrides for object options.
optionLabel: {
type: Function,
default: null,
docs: {
description:
"Resolver override for an object option's display label — `(option) => string`. Falls back to the option's `.label` property.",
},
},
optionValue: {
type: Function,
default: null,
docs: {
description:
"Resolver override for an object option's committed value — `(option) => value`. Falls back to the option's `.value` property.",
},
},
optionDisabled: {
type: Function,
default: null,
docs: {
description:
'Resolver override marking an option non-selectable — `(option) => boolean`. Falls back to the option\'s `.disabled` property.',
},
},
// Stable id base for the ARIA wiring (listbox id + per-option ids +
// aria-activedescendant). Required-distinct for multiple instances on one
// page (the standard ARIA-widget contract).
id: {
type: String,
default: 'rozie-listbox',
docs: {
description:
'Stable id base for the ARIA wiring (the listbox id, per-option ids, and `aria-activedescendant`). Give each instance on a page a distinct id so these references stay unique.',
},
},
// Accessible name for the control when there is no visible <label for>.
ariaLabel: {
type: String,
default: null,
docs: {
description:
'Accessible name for the control when there is no visible `<label for>` pointing at its `id` (`aria-label`).',
},
},
// ── Vertical option windowing (Phase 64 P4, SC-5) ──────────────────────
// Opt-in long-list windowing. When `true`, only the visible slice of options
// renders inside the bounded, scrolling list (with leading/trailing spacer
// rows preserving total scroll height), windowing over the FILTERED option set
// via the shared @rozie-ui/headless-core/windowing.rzts math (the same
// virtual-core bridge data-table uses, wired here per-consumer with a no-op pin
// hook). Default `false` is byte-identical to a non-windowed listbox.
virtual: {
type: Boolean,
default: false,
docs: {
description:
'Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling list (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed listbox. Pair with `inline` + `maxHeight` so the windowed scroll container is bounded.',
},
},
// Estimated option row height (px) seeding the windowing engine before
// measureElement refines actual heights. Only consulted when `virtual` is on.
estimateRowHeight: {
type: Number,
default: 36,
docs: {
description:
'Estimated option row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.',
},
},
// A CSS length string bounding the list scroll container when `virtual` is on
// (e.g. '320px'). Mirrored to the --rozie-listbox-max-height token.
maxHeight: {
type: String,
default: '',
docs: {
description:
"A CSS length string bounding the list scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-listbox-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.",
},
},
}
</props>
<data>
{
open: false,
// Virtual focus: index into the visible option list, or -1.
activeIndex: -1,
// Kept at '' for select-only: the shared spine's `visibleOptions` reads it (the
// identity path when empty) and the `#empty` slot exposes it. No <input> writes
// it now that the editable-input mode is retired (P3/D-03).
query: '',
// ── Windowing host state (Phase 64 P4) — the windowing.rzts host contract ──
// `rows` is the indexable full model the windowing math maps the slice over
// (kept === windowSource() / visibleOptions()); windowVer/editVer are the
// window/edit version reactivity bumps (editVer is inert here — the no-op pin
// hook never bumps it — but the shared math reads it, so it must exist).
rows: [],
windowVer: 0,
editVer: 0,
}
</data>
<script>
// Type-ahead buffer for the select-only listbox trigger. Module-scope
// `let`s reassigned from handlers → the React emitter hoists them to `useRef`
// so they persist across renders (the setup-once guarantee); no-op elsewhere.
// They STAY in this host (not the shared spine) per the A==B rule: reassigned
// module-`let`s + sigils live in the host; the partial only closes over them.
let typeBuffer = ''
let typeTimer = null
// ---- shared list spine (P2: @rozie-ui/headless-core/listCore.rzts) ------
// The option resolvers, client filter, enabled-index navigation, the keyboard
// reducer, type-ahead, single+multi selection, open/close state, and
// activeDescendant derivation now live in the shared, focus-/input-mode
// parameterized list spine. It is a compile-time `.rzts` script-partial: it
// dissolves into this leaf via inlineScriptPartials() before IR lowering (zero
// runtime dep). Listbox consumes it in focus-model `activedescendant` +
// input-mode `select-only` + multi + type-ahead. The spine closes over this
// host's pieces by convention: the reassigned module-`let`s typeBuffer/typeTimer
// (above) and the impure ref fns focusControl/scrollActiveIntoView (below).
import { labelOf, valueOf, disabledOf, optionId, visibleOptions, selectedLabel, activeDescendant, isSelected, resolveInitialActive, applyExpanded, open, close, toggle, fireChange, select, clear, nextEnabled, move, moveEdge, commitActive, onTypeahead, onControlKeyDown, onOptionPointerMove } from '@rozie-ui/headless-core/listCore.rzts'
// ---- shared windowing math (Phase 64 P4: @rozie-ui/headless-core/windowing.rzts) ----
// The PURE windowing math (the windowed slice + spacer geometry) is the SAME
// virtual-core bridge data-table consumes. It is a compile-time `.rzts` partial:
// the imported symbols DISSOLVE into this leaf via inlineScriptPartials() before
// IR lowering (zero runtime dep). The math closes over this host's pieces BY
// CONVENTION (D-04/D-05): windowSource(), the virtualizer instance + gridScrollEl +
// the virtual-core fns, scheduleRemeasure(), $data.windowVer/$data.editVer, and the
// no-op pin hook pinnedEditIndex()/pinnedMeasurement() defined below. The impure
// DOM/refs pieces stay HERE per-consumer (ROZ123). NB the pin hooks are defined in
// THIS host (not the shared partial) so data-table's A==B byte-identity is untouched.
import { virtualItemKey, virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow } from '@rozie-ui/headless-core/windowing.rzts'
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally
// (a peer dep); every RUNTIME reference sits behind `if ($props.virtual)` / a
// `virtualizer` guard so the non-virtual emitted path executes none of it
// (byte-identical-off).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core'
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
let virtualizer = null
let virtualizerCleanup = null
let gridScrollEl = null
let remeasurePending = false
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// set. CR-02: the shared windowing contract requires each row to carry a STABLE `.id`
// (windowing.rzts virtualItemKey reads src[i].id, and the windowed template keys on
// wr.row.id). A raw Listbox option is a primitive or a bare { label, value, disabled }
// — NOT guaranteed to have `.id` — so an unwrapped raw set keyed on wr.row.id collapses
// every framework :key (and every virtual-core measurement key) to `undefined`, which
// recycles the wrong DOM node as the window scrolls. Wrap each option into an id-bearing
// row the way the sibling Combobox's filteredOptions() does — `id` is the resolved
// value, `_opt` the original option (read via wr.row._opt in the windowed template),
// `_i` the source index. Kept === $data.rows so the math's rowList[vi.index] resolves to
// the same wrapped row the count windows over.
const windowSource = () => visibleOptions().map((o, i) => ({ id: valueOf(o), _opt: o, _i: i }))
// D-05 NO-OP PIN HOOK (defined in THIS host, NOT the shared partial — keeps data-table
// A==B intact). The shared windowedRows/padTop/padBottom call pinnedEditIndex()/
// pinnedMeasurement() UNGUARDED by convention; a listbox has no edit-pinning, so these
// reduce the pin union (-1 → never unioned) and the spacer subtraction (null → identity)
// to a no-op. They MUST exist or the by-convention call ReferenceErrors at mount.
const pinnedEditIndex = () => -1
const pinnedMeasurement = (pin) => null
// Keep $data.rows === windowSource() so the windowing math indexes the live option set.
const syncRows = () => { $data.rows = windowSource() }
// Defer remeasureWindow() until AFTER the framework commits the recycled window
// (onChange fires BEFORE React/Solid commit). TWO deferred passes (microtask THEN rAF)
// behind one in-flight flag (the data-table virtualization.rzts:46-56 pattern, copied
// per-consumer per D-04/D-09): the microtask catches Solid's <For> / Svelte's {#each}
// SYNCHRONOUS commit (the Phase 63 Solid under-convergence hazard — D-09 rAF-defer
// budget), the rAF catches React's async commit. measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
const scheduleRemeasure = () => {
if (remeasurePending) return
remeasurePending = true
let ranMicro = false
const microPass = () => { remeasureWindow() }
const rafPass = () => { remeasurePending = false; remeasureWindow() }
if (typeof queueMicrotask !== 'undefined') { ranMicro = true; queueMicrotask(microPass) }
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass)
else if (ranMicro) remeasurePending = false
else setTimeout(rafPass, 0)
}
// measureElement sweep: hand every rendered windowed option to the virtualizer so its
// true (variable) height is observed (virtual-core measures ONLY nodes passed to
// measureElement, keyed by the data-index attribute). Bails during a programmatic
// scroll (scrollToIndex) so a measure can't starve the scroll target.
const remeasureWindow = () => {
if (!virtualizer || !gridScrollEl) return
if (virtualizer.scrollState) return
const els = gridScrollEl.querySelectorAll('.rozie-listbox-option[data-index]')
for (const el of els) virtualizer.measureElement(el)
}
// ---- focus / scroll helpers (post-mount $refs only) --------------------
// Impure ($refs) → per the ROZ123 + A==B rules they stay in the host (the spine
// only closes over them). Named `focusControl` (not `focus`): a `focus` $expose
// verb would override the inherited HTMLElement.focus method on the Lit element.
const focusControl = () => {
$refs.triggerEl?.focus()
}
// Keep the active option visible inside the scrolling listbox. Reads $refs in
// a post-mount callback only (never eagerly — ROZ123). When windowing, route through
// the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window is
// scrolled into view (the windowed-arrow-nav seam); else the native scrollIntoView.
const scrollActiveIntoView = () => {
if ($data.activeIndex < 0) return
if ($props.virtual && virtualizer) {
// 'center' (not 'auto'): keep the active option well inside the rendered slice as the
// window scrolls — 'auto' lands it at the viewport edge where the overscan band can
// leave it just-unrendered for a frame on the fine-grained targets (Solid).
virtualizer.scrollToIndex($data.activeIndex, { align: 'center' })
scheduleRemeasure()
return
}
if (!$refs.listEl) return
const el = $refs.listEl.querySelector('#' + CSS.escape(optionId($data.activeIndex)))
el?.scrollIntoView({ block: 'nearest' })
}
// ---- windowing lifecycle (post-mount; ONLY when virtual) ----------------
// kickWindow: the cross-target first-paint settle. Re-captures the LIVE scroll element,
// re-feeds the CURRENT option count into the virtualizer, re-attaches its rect observer
// (_willUpdate), and bumps the windowVer signal so the windowed <For>/{#each}/repeat
// re-derives. Retried over a few frames because (a) virtual-core measures the scroll rect
// asynchronously (D-09 Solid rAF-defer — a synchronous kick sees rectH 0 → empty window),
// (b) Solid/Lit recreate the list node between mount and first commit (leaving virtual-core's
// scrollElement stale), and (c) the consumer often seeds options AFTER the listbox mounts
// (Lit/React), so the count must be re-read once the prop propagates. Stops once the window
// paints (or attempts run out) — idempotent + loop-free.
const kickWindow = (attempts) => {
if (!virtualizer) return
gridScrollEl = $el ? $el.querySelector('.rozie-listbox-list') : gridScrollEl
// Only re-feed the count from a NON-EMPTY source: on React these rAF closures capture
// stale (mount-time, empty) props, so feeding here would CLOBBER the $watch's correct
// count back to 0. The $watch (fresh useEffect props) owns React's count; the kick owns
// the Solid/Lit scroll-element re-attach + the deferred windowVer re-derive.
if (windowSource().length > 0) {
syncRows()
virtualizer.setOptions(virtualizerOptions())
}
virtualizer._willUpdate()
$data.windowVer = $data.windowVer + 1
remeasureWindow()
if (windowedRows().length === 0 && attempts > 0) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(attempts - 1))
else setTimeout(() => kickWindow(attempts - 1), 16)
}
}
$onMount(() => {
syncRows()
if ($props.virtual) {
// The list renders at mount when virtual, so the .rozie-listbox-list scroll container
// exists here. Capture it via $el.querySelector (the data-table gridScrollEl precedent,
// proven ×6 incl Lit shadow + Solid) — $refs on a conditionally-rendered node is null on
// Solid/Lit, which leaves the virtualizer with no scroll element.
gridScrollEl = $el ? $el.querySelector('.rozie-listbox-list') : null
virtualizer = new Virtualizer(virtualizerOptions())
virtualizerCleanup = virtualizer._didMount()
$data.windowVer = $data.windowVer + 1
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(8))
else setTimeout(() => kickWindow(8), 0)
}
})
// Reactive re-feed: when the option set (or the query filter) changes, re-sync the
// indexable rows + push fresh options into the virtualizer and re-pull the window
// (the data-table setOptions+_willUpdate re-feed precedent). Watch a derived primitive
// (length + query), never a freshly-built array (the stale-array Pitfall).
$watch(() => (($props.options ? $props.options.length : 0) + '|' + $data.query), () => {
syncRows()
if ($props.virtual && virtualizer) {
gridScrollEl = $el ? $el.querySelector('.rozie-listbox-list') : gridScrollEl
virtualizer.setOptions(virtualizerOptions())
virtualizer._willUpdate()
$data.windowVer = $data.windowVer + 1
scheduleRemeasure()
}
})
$onUnmount(() => {
if (typeTimer !== null) clearTimeout(typeTimer)
// Tear down the virtualizer's scroll-element ResizeObserver (no-op when virtual off).
if (virtualizerCleanup) virtualizerCleanup()
})
// Imperative handle. Shorthand keys (aliased `{ open: fn }` keys are dropped by
// the React emitter) — every function is named exactly as its verb.
$expose({ open, close, toggle, clear, focusControl })
</script>
<listeners>
<listener :target="document" @click.outside($refs.controlEl,$refs.listEl)="close" r-if="$data.open" />
</listeners>
<template>
<div class="rozie-listbox" :class="{ 'rozie-listbox-open': $data.open, 'rozie-listbox-disabled': $props.disabled, 'rozie-listbox-inline': $props.inline }">
<!-- Control: the select-only trigger button. It carries role="combobox" per the
ARIA APG "Select-Only Combobox" pattern (DOM focus stays here; the active
option is tracked virtually via aria-activedescendant). -->
<div class="rozie-listbox-control" ref="controlEl">
<button
ref="triggerEl"
type="button"
class="rozie-listbox-trigger"
role="combobox"
aria-haspopup="listbox"
:aria-expanded="$data.open"
:aria-controls="$props.id + '-list'"
:aria-activedescendant="activeDescendant"
:aria-label="$props.ariaLabel"
:disabled="$props.disabled"
@click="toggle"
@keydown="onControlKeyDown($event)"
>
<slot name="selected" :selected="selectedLabel" :value="$props.value">
<span r-if="selectedLabel" class="rozie-listbox-selected">{{ selectedLabel }}</span>
<span r-else class="rozie-listbox-placeholder">{{ $props.placeholder }}</span>
</slot>
<span class="rozie-listbox-arrow" aria-hidden="true">▾</span>
</button>
</div>
<!-- Popup listbox (NON-VIRTUAL). aria-activedescendant on the control points at the
highlighted option id; DOM focus never leaves the control. Byte-identical to the
pre-windowing render — the `&& !$props.virtual` only routes virtual usage to the
windowed branch below; with virtual off this is exactly the prior behavior. -->
<div
r-if="$data.open && !$props.virtual"
ref="listEl"
class="rozie-listbox-list"
role="listbox"
:id="$props.id + '-list'"
:aria-label="$props.ariaLabel"
:aria-multiselectable="$props.multiple"
>
<div
r-for="opt, index in visibleOptions()"
:key="optionId(index)"
:id="optionId(index)"
class="rozie-listbox-option"
:class="{ 'is-active': $data.activeIndex === index, 'is-selected': isSelected(opt), 'is-disabled': disabledOf(opt) }"
role="option"
:aria-selected="!!isSelected(opt)"
:aria-disabled="!!disabledOf(opt)"
@click="select(opt)"
@mousemove="onOptionPointerMove(index)"
>
<slot name="option" :option="opt" :index="index" :active="$data.activeIndex === index" :selected="isSelected(opt)" :disabled="disabledOf(opt)">
{{ labelOf(opt) }}
</slot>
</div>
<div r-if="visibleOptions().length === 0" class="rozie-listbox-empty" role="presentation">
<slot name="empty" :query="$data.query">No options</slot>
</div>
</div>
<!-- ══ WINDOWED listbox (Phase 64 P4, SC-5) — emitted/active ONLY when $props.virtual ══
Stays MOUNTED whenever virtual (so the .rozie-listbox-list scroll container exists at
mount for the virtualizer — ROZ123-safe) but is HIDDEN via display:none whenever the
listbox is closed (CR-01): unmounting would drop the virtualizer's scroll element, so
we hide-not-unmount to keep its scrollTop + measurements while still honoring
toggle()/Escape/outside-click close semantics (the non-virtual branch is gated on
$data.open; this branch mirrors that via the :style display toggle). display:none also
drops the role="listbox" from the a11y tree when collapsed, so :aria-expanded on the
control stays consistent. Renders a leading spacer, the windowed { vi, row } slice
keyed on the wrapper id (CR-02: windowSource() wraps each option into an id-bearing
row), and a trailing spacer; the option's full-model index is wr.vi.index. The
container is bounded/scrolling via the base .rozie-listbox-list CSS (max-height from
the --rozie-listbox-max-height token, mirrored from the maxHeight prop). -->
<div
r-if="$props.virtual"
ref="listEl"
class="rozie-listbox-list rozie-listbox-list--virtual"
role="listbox"
:id="$props.id + '-list'"
:aria-label="$props.ariaLabel"
:aria-multiselectable="$props.multiple"
:style="($data.open ? '' : 'display:none;') + ($props.maxHeight ? ('height:' + $props.maxHeight + ';max-height:' + $props.maxHeight + ';overflow-y:auto;--rozie-listbox-max-height:' + $props.maxHeight) : 'overflow-y:auto')"
>
<div class="rozie-listbox-spacer" aria-hidden="true" :style="'height:' + padTop() + 'px'"></div>
<div
r-for="wr in windowedRows()"
:key="wr.row.id"
:id="optionId(wr.vi.index)"
:data-index="wr.vi.index"
class="rozie-listbox-option"
:class="{ 'is-active': $data.activeIndex === wr.vi.index, 'is-selected': isSelected(wr.row._opt), 'is-disabled': disabledOf(wr.row._opt) }"
role="option"
:aria-selected="!!isSelected(wr.row._opt)"
:aria-disabled="!!disabledOf(wr.row._opt)"
@click="select(wr.row._opt)"
@mousemove="onOptionPointerMove(wr.vi.index)"
>
<slot name="option" :option="wr.row._opt" :index="wr.vi.index" :active="$data.activeIndex === wr.vi.index" :selected="isSelected(wr.row._opt)" :disabled="disabledOf(wr.row._opt)">
{{ labelOf(wr.row._opt) }}
</slot>
</div>
<div class="rozie-listbox-spacer" aria-hidden="true" :style="'height:' + padBottom() + 'px'"></div>
<div r-if="windowSource().length === 0" class="rozie-listbox-empty" role="presentation">
<slot name="empty" :query="$data.query">No options</slot>
</div>
</div>
</div>
</template>
<style>
/*
Fully token-driven. EVERY value is a `var(--rozie-listbox-*, <fallback>)`, so
the component renders with zero configuration yet is completely re-skinnable
by setting tokens at any ancestor scope (`:root`, `.dark`, a wrapper, or the
`.rozie-listbox` element itself). The shipped `themes/*.css` presets do exactly
that — mapping these tokens onto shadcn/Radix, Material 3, and Bootstrap 5.
Nested var() fallbacks let one token derive from another (ring ← accent,
popup-bg ← bg) while staying independently overridable.
*/
.rozie-listbox {
position: relative;
display: inline-block;
min-width: var(--rozie-listbox-min-width, 12rem);
font: var(--rozie-listbox-font, inherit);
}
.rozie-listbox-control { display: block; }
.rozie-listbox-input,
.rozie-listbox-trigger {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
padding: var(--rozie-listbox-control-padding, 0.5rem 0.75rem);
font: inherit;
text-align: left;
background: var(--rozie-listbox-bg, #fff);
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-border, rgba(0, 0, 0, 0.2));
border-radius: var(--rozie-listbox-radius, 6px);
cursor: pointer;
}
.rozie-listbox-input { cursor: text; }
.rozie-listbox-input:focus-visible,
.rozie-listbox-input:focus,
.rozie-listbox-trigger:focus-visible,
.rozie-listbox-trigger:focus {
outline: var(--rozie-listbox-ring-width, 2px) solid var(--rozie-listbox-ring, var(--rozie-listbox-accent, #0066cc));
outline-offset: var(--rozie-listbox-ring-offset, 1px);
}
.rozie-listbox-disabled { opacity: var(--rozie-listbox-disabled-opacity, 0.6); pointer-events: none; }
.rozie-listbox-placeholder { color: var(--rozie-listbox-placeholder, rgba(0, 0, 0, 0.45)); }
.rozie-listbox-arrow {
font-size: 0.75em;
color: var(--rozie-listbox-arrow-color, currentColor);
opacity: var(--rozie-listbox-arrow-opacity, 0.7);
}
.rozie-listbox-list {
position: absolute;
z-index: var(--rozie-listbox-z, 1000);
top: calc(100% + var(--rozie-listbox-popup-offset, 4px));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-listbox-popup-padding, 0.25rem);
max-height: var(--rozie-listbox-max-height, 16rem);
overflow-y: auto;
list-style: none;
background: var(--rozie-listbox-popup-bg, var(--rozie-listbox-bg, #fff));
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-popup-border, var(--rozie-listbox-border, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-listbox-popup-radius, var(--rozie-listbox-radius, 6px));
box-shadow: var(--rozie-listbox-shadow, 0 6px 24px rgba(0, 0, 0, 0.12));
}
/* Inline mode: the root fills its container (so the control + list span the full
width even when a host wrapper — lit custom element / angular host — sits
between it and a stretching flex parent), and the list renders IN FLOW so an
overflow-clipped ancestor (e.g. a command-palette panel) can't cull it. */
.rozie-listbox-inline {
display: block;
width: 100%;
}
.rozie-listbox-inline .rozie-listbox-list {
position: static;
margin-top: var(--rozie-listbox-popup-offset, 4px);
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-listbox-option {
padding: var(--rozie-listbox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-listbox-option-radius, 4px);
color: var(--rozie-listbox-option-fg, inherit);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
}
.rozie-listbox-option.is-active {
background: var(--rozie-listbox-active-bg, rgba(0, 102, 204, 0.12));
color: var(--rozie-listbox-active-fg, inherit);
}
.rozie-listbox-option.is-selected {
background: var(--rozie-listbox-selected-bg, transparent);
color: var(--rozie-listbox-selected-fg, inherit);
font-weight: var(--rozie-listbox-selected-weight, 600);
}
.rozie-listbox-option.is-selected::after {
content: var(--rozie-listbox-check, '✓');
color: var(--rozie-listbox-check-color, var(--rozie-listbox-accent, #0066cc));
}
.rozie-listbox-option.is-disabled { opacity: var(--rozie-listbox-disabled-opacity, 0.45); cursor: not-allowed; }
.rozie-listbox-empty { padding: var(--rozie-listbox-option-padding, 0.5rem 0.6rem); color: var(--rozie-listbox-empty-fg, rgba(0, 0, 0, 0.5)); }
/* Windowing spacer rows (Phase 64 P4): zero-chrome blocks whose inline height keeps the
total scroll height === virtual-core getTotalSize() (the windowed slice sits between). */
.rozie-listbox-spacer { margin: 0; padding: 0; border: 0; flex: none; }
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/listbox-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, parseInlineStyle, rozieAttr, rozieDisplay, useControllableState, useOutsideClick } from '@rozie/runtime-react';
import './Listbox.css';
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally
// (a peer dep); every RUNTIME reference sits behind `if ($props.virtual)` / a
// `virtualizer` guard so the non-virtual emitted path executes none of it
// (byte-identical-off).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
interface SelectedCtx { selected: any; value: any; }
interface OptionCtx { option: any; index: any; active: any; selected: any; disabled: any; }
interface EmptyCtx { query: any; }
interface ListboxProps {
/**
* The option set. Each entry is either a primitive (`string`/`number`) or an object; objects resolve their label, value, and disabled state via the `option*` resolver props, falling back to `.label` / `.value` / `.disabled`.
*/
options?: any[];
/**
* The selected value (two-way `r-model`) — a scalar in single-select, an array of values in multi-select. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Listbox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
* @example
* <Listbox r-model:value="fruit" :options="fruits" />
*/
value?: (unknown) | null;
defaultValue?: (unknown) | null;
onValueChange?: (value: (unknown) | null) => void;
/**
* Enable multi-select: `value` becomes an array, selecting an option toggles its membership, and the popup stays open after each commit.
*/
multiple?: boolean;
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the listbox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
inline?: boolean;
/**
* Disable the control entirely. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Placeholder text shown in the empty control.
*/
placeholder?: string;
/**
* Close the popup after a single-select commit. Defaults `true`; multi-select keeps the popup open regardless of this setting.
*/
closeOnSelect?: boolean;
/**
* Resolver override for an object option's display label — `(option) => string`. Falls back to the option's `.label` property.
*/
optionLabel?: ((...args: any[]) => any) | null;
/**
* Resolver override for an object option's committed value — `(option) => value`. Falls back to the option's `.value` property.
*/
optionValue?: ((...args: any[]) => any) | null;
/**
* Resolver override marking an option non-selectable — `(option) => boolean`. Falls back to the option's `.disabled` property.
*/
optionDisabled?: ((...args: any[]) => any) | null;
/**
* Stable id base for the ARIA wiring (the listbox id, per-option ids, and `aria-activedescendant`). Give each instance on a page a distinct id so these references stay unique.
*/
id?: string;
/**
* Accessible name for the control when there is no visible `<label for>` pointing at its `id` (`aria-label`).
*/
ariaLabel?: (string) | null;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling list (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed listbox. Pair with `inline` + `maxHeight` so the windowed scroll container is bounded.
*/
virtual?: boolean;
/**
* Estimated option row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight?: number;
/**
* A CSS length string bounding the list scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-listbox-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight?: string;
onOpenChange?: (...args: any[]) => void;
onChange?: (...args: any[]) => void;
renderSelected?: (ctx: SelectedCtx) => ReactNode;
renderOption?: (ctx: OptionCtx) => ReactNode;
renderEmpty?: (ctx: EmptyCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface ListboxHandle {
open: (...args: any[]) => any;
close: (...args: any[]) => any;
toggle: (...args: any[]) => any;
clear: (...args: any[]) => any;
focusControl: (...args: any[]) => any;
}
const Listbox = forwardRef<ListboxHandle, ListboxProps>(function Listbox(_props: ListboxProps, ref): JSX.Element {
const __defaultOptions = useState(() => (() => [])())[0];
const props: Omit<ListboxProps, 'options' | 'multiple' | 'inline' | 'disabled' | 'placeholder' | 'closeOnSelect' | 'optionLabel' | 'optionValue' | 'optionDisabled' | 'id' | 'ariaLabel' | 'virtual' | 'estimateRowHeight' | 'maxHeight'> & { options: any[]; multiple: boolean; inline: boolean; disabled: boolean; placeholder: string; closeOnSelect: boolean; optionLabel: ((...args: any[]) => any) | null; optionValue: ((...args: any[]) => any) | null; optionDisabled: ((...args: any[]) => any) | null; id: string; ariaLabel: (string) | null; virtual: boolean; estimateRowHeight: number; maxHeight: string } = {
..._props,
options: _props.options ?? __defaultOptions,
multiple: _props.multiple ?? false,
inline: _props.inline ?? false,
disabled: _props.disabled ?? false,
placeholder: _props.placeholder ?? '',
closeOnSelect: _props.closeOnSelect ?? true,
optionLabel: _props.optionLabel ?? null,
optionValue: _props.optionValue ?? null,
optionDisabled: _props.optionDisabled ?? null,
id: _props.id ?? 'rozie-listbox',
ariaLabel: _props.ariaLabel ?? null,
virtual: _props.virtual ?? false,
estimateRowHeight: _props.estimateRowHeight ?? 36,
maxHeight: _props.maxHeight ?? '',
};
const attrs: Record<string, unknown> = (() => {
const { options, value, multiple, inline, disabled, placeholder, closeOnSelect, optionLabel, optionValue, optionDisabled, id, ariaLabel, virtual, estimateRowHeight, maxHeight, defaultValue, onValueChange, ...rest } = _props as ListboxProps & Record<string, unknown>;
void options; void value; void multiple; void inline; void disabled; void placeholder; void closeOnSelect; void optionLabel; void optionValue; void optionDisabled; void id; void ariaLabel; void virtual; void estimateRowHeight; void maxHeight; void defaultValue; void onValueChange;
return rest;
})();
const gridScrollEl = useRef<any>(null);
const virtualizer = useRef<any>(null);
const virtualizerCleanup = useRef<any>(null);
const remeasurePending = useRef(false);
const typeTimer = useRef<any>(null);
const [value, setValue] = useControllableState({
value: props.value,
defaultValue: props.defaultValue ?? null,
onValueChange: props.onValueChange,
});
const [open$local, setOpen$local] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [query, setQuery] = useState('');
const [rows, setRows] = useState<any[]>([]);
const [windowVer, setWindowVer] = useState(0);
const [editVer, setEditVer] = useState(0);
const controlEl = useRef<HTMLDivElement | null>(null);
const triggerEl = useRef<HTMLButtonElement | null>(null);
const listEl = useRef<HTMLDivElement | null>(null);
const __rozieRoot = useRef<HTMLDivElement | null>(null);
const selectedLabel = useMemo(() => {
const cur = value;
if (props.multiple) {
// Read the model value into a local before narrowing: `$props.value` lowers
// to a `value()` accessor on Solid, and Array.isArray() can't narrow two
// separate calls — narrowing one stable local works on every target.
const arr = Array.isArray(cur) ? cur : [];
if (arr.length === 0) return '';
return props.options.filter((o: any) => arr.includes(valueOf(o))).map(labelOf).join(', ');
}
const match = props.options.find((o: any) => valueOf(o) === cur);
return match === undefined ? '' : labelOf(match);
}, [Array, labelOf, props.multiple, props.options, value, valueOf]);
const activeDescendant = useMemo(() => {
if (!open$local || activeIndex < 0) return null;
return optionId(activeIndex);
}, [activeIndex, open$local, optionId]);
const _watch0First = useRef(true);
// Type-ahead buffer for the select-only listbox trigger. Module-scope
// `let`s reassigned from handlers → the React emitter hoists them to `useRef`
// so they persist across renders (the setup-once guarantee); no-op elsewhere.
// They STAY in this host (not the shared spine) per the A==B rule: reassigned
// module-`let`s + sigils live in the host; the partial only closes over them.
let typeBuffer = '';
function labelOf(opt: any) {
if (props.optionLabel !== null) return props.optionLabel(opt);
if (opt !== null && typeof opt === 'object' && 'label' in opt) return opt.label;
return String(opt);
}
function valueOf(opt: any) {
if (props.optionValue !== null) return props.optionValue(opt);
if (opt !== null && typeof opt === 'object' && 'value' in opt) return opt.value;
return opt;
}
function disabledOf(opt: any) {
if (props.optionDisabled !== null) return !!props.optionDisabled(opt);
if (opt !== null && typeof opt === 'object' && 'disabled' in opt) return !!opt.disabled;
return false;
}
function optionId(index: any) {
return props.id + '-opt-' + index;
}
function visibleOptions() {
const q = (query || '').trim().toLowerCase();
if (q === '') return props.options;
return props.options.filter((opt: any) => labelOf(opt).toLowerCase().includes(q));
}
function isSelected(opt: any) {
const v = valueOf(opt);
const cur = value;
if (props.multiple) return Array.isArray(cur) && cur.includes(v);
return cur === v;
}
function resolveInitialActive() {
const opts = visibleOptions();
const sel = opts.findIndex((o: any) => isSelected(o) && !disabledOf(o));
if (sel !== -1) return sel;
return opts.findIndex((o: any) => !disabledOf(o));
}
function applyExpanded(next: any) {
if (next && props.disabled) return;
if (open$local === next) return;
setOpen$local(next);
setActiveIndex(next ? resolveInitialActive() : -1);
props.onOpenChange && props.onOpenChange({
open: next
});
}
function open() {
return applyExpanded(true);
}
const close = useCallback(() => applyExpanded(false), [applyExpanded]);
const toggle = useCallback(() => applyExpanded(!open$local), [applyExpanded, open$local]);
function fireChange(value: any, option: any) {
return props.onChange && props.onChange({
value,
option
});
}
const select = useCallback((opt: any) => {
if (disabledOf(opt)) return;
const v = valueOf(opt);
if (props.multiple) {
const cur = value;
const arr = Array.isArray(cur) ? cur : [];
// Fresh array on every commit — in-place mutation is dropped by the
// React/Solid/Lit/Angular change detectors.
const next = arr.includes(v) ? arr.filter((x: any) => x !== v) : [...arr, v];
setValue(next);
fireChange(next, opt);
} else {
setValue(v);
fireChange(v, opt);
if (props.closeOnSelect) {
close();
focusControl();
}
}
}, [close, disabledOf, fireChange, focusControl, props.closeOnSelect, props.multiple, setValue, value, valueOf]);
function clear() {
const empty = props.multiple ? [] : null;
setValue(empty);
setQuery('');
fireChange(empty, null);
}
function nextEnabled(from: any, dir: any) {
const opts = visibleOptions();
if (opts.length === 0) return -1;
let i = from;
for (let step = 0; step < opts.length; step++) {
i += dir;
if (i < 0) i = opts.length - 1;else if (i >= opts.length) i = 0;
if (!disabledOf(opts[i])) return i;
}
return from;
}
function move(dir: any) {
if (!open$local) {
open();
return;
}
const start = activeIndex < 0 ? dir > 0 ? -1 : 0 : activeIndex;
setActiveIndex(nextEnabled(start, dir));
scrollActiveIntoView();
}
function moveEdge(toEnd: any) {
if (!open$local) open();
setActiveIndex(toEnd ? nextEnabled(-1, -1) : nextEnabled(-1, 1));
scrollActiveIntoView();
}
function commitActive() {
const opts = visibleOptions();
if (activeIndex >= 0 && activeIndex < opts.length) select(opts[activeIndex]);
}
function onTypeahead(ch: any) {
if (typeTimer.current !== null) clearTimeout(typeTimer.current);
typeBuffer += ch.toLowerCase();
typeTimer.current = setTimeout(() => {
typeBuffer = '';
}, 600);
const opts = visibleOptions();
const idx = opts.findIndex((o: any) => !disabledOf(o) && labelOf(o).toLowerCase().startsWith(typeBuffer));
if (idx !== -1) {
if (!open$local) open();
setActiveIndex(idx);
scrollActiveIntoView();
}
}
const onControlKeyDown = useCallback(($event: any) => {
const key = $event.key;
if (key === 'ArrowDown') {
$event.preventDefault();
move(1);
} else if (key === 'ArrowUp') {
$event.preventDefault();
move(-1);
} else if (key === 'Home') {
$event.preventDefault();
moveEdge(false);
} else if (key === 'End') {
$event.preventDefault();
moveEdge(true);
} else if (key === 'Enter') {
if (open$local) {
$event.preventDefault();
commitActive();
}
} else if (key === 'Escape') {
if (open$local) {
$event.preventDefault();
close();
focusControl();
}
} else if (key === ' ' || key === 'Spacebar') {
// Space toggles / commits in a select-only host (a button trigger). A
// filter-input host types the literal space into its <input> and does NOT
// route Space through this reducer, so this branch is select-only by use.
$event.preventDefault();
if (!open$local) open();else commitActive();
} else if (key === 'Tab') {
if (open$local) close();
} else if (key.length === 1 && !$event.metaKey && !$event.ctrlKey && !$event.altKey) {
onTypeahead(key);
}
}, [close, commitActive, focusControl, move, moveEdge, onTypeahead, open, open$local]);
const onOptionPointerMove = useCallback((index: any) => {
if (activeIndex !== index) setActiveIndex(index);
}, [activeIndex]);
function virtualItemKey(i: any) {
const src = windowSource();
return src && src[i] ? src[i].id : undefined;
}
const virtualizerOptions = useCallback((): any => ({
count: windowSource().length,
getScrollElement: () => gridScrollEl.current,
estimateSize: () => props.estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
setWindowVer(prev => prev + 1);
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
scheduleRemeasure();
}
}), [props.estimateRowHeight, scheduleRemeasure, virtualItemKey, windowSource]);
function pinMeasurement(pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null {
return pinnedMeasurement(pin);
}
function windowedRows() {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void windowVer;
void editVer;
if (!virtualizer.current) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!props.virtual) {
const rowList = rows || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = virtualizer.current.getVirtualItems();
const rowList = rows || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
}
function padTop() {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void windowVer;
void editVer;
if (!props.virtual || !virtualizer.current) return 0;
const items = virtualizer.current.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
}
function padBottom() {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void windowVer;
void editVer;
if (!props.virtual || !virtualizer.current) return 0;
const items = virtualizer.current.getVirtualItems();
if (!items.length) return 0;
let pad = virtualizer.current.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
}
function pmIndexInWindow(items: any, idx: any) {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
}
function rowIsOutsideWindow(r: any) {
if (!props.virtual || !virtualizer.current) return false;
const items = virtualizer.current.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
}
function windowSource() {
return visibleOptions().map((o: any, i: any) => ({
id: valueOf(o),
_opt: o,
_i: i
}));
}
function pinnedEditIndex() {
return -1;
}
function pinnedMeasurement(pin: any) {
return null;
}
const syncRows = useCallback(() => {
setRows(windowSource());
}, [windowSource]);
function scheduleRemeasure() {
if (remeasurePending.current) return;
remeasurePending.current = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
const rafPass = () => {
remeasurePending.current = false;
remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending.current = false;else setTimeout(rafPass, 0);
}
function remeasureWindow() {
if (!virtualizer.current || !gridScrollEl.current) return;
if (virtualizer.current.scrollState) return;
const els = gridScrollEl.current.querySelectorAll('.rozie-listbox-option[data-index]');
for (const el of els as any) virtualizer.current.measureElement(el);
}
function focusControl() {
triggerEl.current?.focus();
}
function scrollActiveIntoView() {
if (activeIndex < 0) return;
if (props.virtual && virtualizer.current) {
// 'center' (not 'auto'): keep the active option well inside the rendered slice as the
// window scrolls — 'auto' lands it at the viewport edge where the overscan band can
// leave it just-unrendered for a frame on the fine-grained targets (Solid).
virtualizer.current.scrollToIndex(activeIndex, {
align: 'center'
});
scheduleRemeasure();
return;
}
if (!listEl.current) return;
const el = listEl.current!.querySelector('#' + CSS.escape(optionId(activeIndex)));
el?.scrollIntoView({
block: 'nearest'
});
}
const kickWindow = useCallback((attempts: any) => {
if (!virtualizer.current) return;
gridScrollEl.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rozie-listbox-list') : gridScrollEl.current;
// Only re-feed the count from a NON-EMPTY source: on React these rAF closures capture
// stale (mount-time, empty) props, so feeding here would CLOBBER the $watch's correct
// count back to 0. The $watch (fresh useEffect props) owns React's count; the kick owns
// the Solid/Lit scroll-element re-attach + the deferred windowVer re-derive.
if (windowSource().length > 0) {
syncRows();
virtualizer.current.setOptions(virtualizerOptions());
}
virtualizer.current._willUpdate();
setWindowVer(prev => prev + 1);
remeasureWindow();
if (windowedRows().length === 0 && attempts > 0) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(attempts - 1));else setTimeout(() => kickWindow(attempts - 1), 16);
}
}, [remeasureWindow, syncRows, virtualizerOptions, windowSource, windowedRows]);
useEffect(() => {
syncRows();
if (props.virtual) {
// The list renders at mount when virtual, so the .rozie-listbox-list scroll container
// exists here. Capture it via $el.querySelector (the data-table gridScrollEl precedent,
// proven ×6 incl Lit shadow + Solid) — $refs on a conditionally-rendered node is null on
// Solid/Lit, which leaves the virtualizer with no scroll element.
gridScrollEl.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rozie-listbox-list') : null;
virtualizer.current = new Virtualizer(virtualizerOptions());
virtualizerCleanup.current = virtualizer.current._didMount();
setWindowVer(prev => prev + 1);
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(8));else setTimeout(() => kickWindow(8), 0);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
if (typeTimer.current !== null) clearTimeout(typeTimer.current);
// Tear down the virtualizer's scroll-element ResizeObserver (no-op when virtual off).
if (virtualizerCleanup.current) virtualizerCleanup.current();
};
}, []);
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
syncRows();
if (props.virtual && virtualizer.current) {
gridScrollEl.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rozie-listbox-list') : gridScrollEl.current;
virtualizer.current.setOptions(virtualizerOptions());
virtualizer.current._willUpdate();
setWindowVer(prev => prev + 1);
scheduleRemeasure();
}
}, [props.options, query]); // eslint-disable-line react-hooks/exhaustive-deps
useOutsideClick(
[controlEl, listEl],
close,
() => !!(open$local),
);
const _rozieExposeRef = useRef({ open, close, toggle, clear, focusControl });
_rozieExposeRef.current = { open, close, toggle, clear, focusControl };
useImperativeHandle(ref, () => ({ open: (...args: Parameters<typeof open>): ReturnType<typeof open> => _rozieExposeRef.current.open(...args), close: (...args: Parameters<typeof close>): ReturnType<typeof close> => _rozieExposeRef.current.close(...args), toggle: (...args: Parameters<typeof toggle>): ReturnType<typeof toggle> => _rozieExposeRef.current.toggle(...args), clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args), focusControl: (...args: Parameters<typeof focusControl>): ReturnType<typeof focusControl> => _rozieExposeRef.current.focusControl(...args) }), []);
return (
<>
<div ref={__rozieRoot} {...attrs} className={clsx(clsx("rozie-listbox", { "rozie-listbox-open": open$local, "rozie-listbox-disabled": props.disabled, "rozie-listbox-inline": props.inline }), (attrs.className as string | undefined))} data-rozie-s-b576227a="">
<div className={"rozie-listbox-control"} ref={controlEl} data-rozie-s-b576227a="">
<button ref={triggerEl} type="button" className={"rozie-listbox-trigger"} role="combobox" aria-haspopup="listbox" aria-expanded={open$local} aria-controls={rozieAttr(props.id + '-list')} aria-activedescendant={rozieAttr(activeDescendant)} aria-label={rozieAttr(props.ariaLabel)} disabled={props.disabled} onClick={toggle} onKeyDown={($event) => { onControlKeyDown($event); }} data-rozie-s-b576227a="">
{(props.renderSelected ?? props.slots?.['selected']) ? ((props.renderSelected ?? props.slots?.['selected']) as Function)({ selected: selectedLabel, value }) : (selectedLabel) ? <span className={"rozie-listbox-selected"} data-rozie-s-b576227a="">{rozieDisplay(selectedLabel)}</span> : <span className={"rozie-listbox-placeholder"} data-rozie-s-b576227a="">{props.placeholder}</span>}
<span className={"rozie-listbox-arrow"} aria-hidden="true" data-rozie-s-b576227a="">▾</span>
</button>
</div>
{(open$local && !props.virtual) && <div ref={listEl} className={"rozie-listbox-list"} role="listbox" id={rozieAttr(props.id + '-list')} aria-label={rozieAttr(props.ariaLabel)} aria-multiselectable={props.multiple} data-rozie-s-b576227a="">
{visibleOptions().map((opt, index) => <div key={optionId(index)} id={rozieAttr(optionId(index))} className={clsx("rozie-listbox-option", { "is-active": activeIndex === index, "is-selected": isSelected(opt), "is-disabled": disabledOf(opt) })} role="option" aria-selected={!!isSelected(opt)} aria-disabled={!!disabledOf(opt)} onClick={($event) => { select(opt); }} onMouseMove={($event) => { onOptionPointerMove(index); }} data-rozie-s-b576227a="">
{(props.renderOption ?? props.slots?.['option']) ? ((props.renderOption ?? props.slots?.['option']) as Function)({ option: opt, index, active: activeIndex === index, selected: isSelected(opt), disabled: disabledOf(opt) }) : rozieDisplay(labelOf(opt))}
</div>)}
{(visibleOptions().length === 0) && <div className={"rozie-listbox-empty"} role="presentation" data-rozie-s-b576227a="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ query }) : "No options"}
</div>}</div>}{(props.virtual) && <div ref={listEl} className={"rozie-listbox-list rozie-listbox-list--virtual"} role="listbox" id={rozieAttr(props.id + '-list')} aria-label={rozieAttr(props.ariaLabel)} aria-multiselectable={props.multiple} style={parseInlineStyle((open$local ? '' : 'display:none;') + (props.maxHeight ? 'height:' + props.maxHeight + ';max-height:' + props.maxHeight + ';overflow-y:auto;--rozie-listbox-max-height:' + props.maxHeight : 'overflow-y:auto'))} data-rozie-s-b576227a="">
<div className={"rozie-listbox-spacer"} aria-hidden="true" style={parseInlineStyle('height:' + padTop() + 'px')} data-rozie-s-b576227a="" />
{windowedRows().map((wr) => <div key={wr.row.id} id={rozieAttr(optionId(wr.vi.index))} data-index={rozieAttr(wr.vi.index)} className={clsx("rozie-listbox-option", { "is-active": activeIndex === wr.vi.index, "is-selected": isSelected(wr.row._opt), "is-disabled": disabledOf(wr.row._opt) })} role="option" aria-selected={!!isSelected(wr.row._opt)} aria-disabled={!!disabledOf(wr.row._opt)} onClick={($event) => { select(wr.row._opt); }} onMouseMove={($event) => { onOptionPointerMove(wr.vi.index); }} data-rozie-s-b576227a="">
{(props.renderOption ?? props.slots?.['option']) ? ((props.renderOption ?? props.slots?.['option']) as Function)({ option: wr.row._opt, index: wr.vi.index, active: activeIndex === wr.vi.index, selected: isSelected(wr.row._opt), disabled: disabledOf(wr.row._opt) }) : rozieDisplay(labelOf(wr.row._opt))}
</div>)}
<div className={"rozie-listbox-spacer"} aria-hidden="true" style={parseInlineStyle('height:' + padBottom() + 'px')} data-rozie-s-b576227a="" />
{(windowSource().length === 0) && <div className={"rozie-listbox-empty"} role="presentation" data-rozie-s-b576227a="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ query }) : "No options"}
</div>}</div>}</div>
</>
);
});
export default Listbox;vue
<template>
<div :class="['rozie-listbox', { 'rozie-listbox-open': open$local, 'rozie-listbox-disabled': props.disabled, 'rozie-listbox-inline': props.inline }]" ref="__rozieRootRef" v-bind="$attrs">
<div class="rozie-listbox-control" ref="controlElRef">
<button ref="triggerElRef" type="button" class="rozie-listbox-trigger" role="combobox" aria-haspopup="listbox" :aria-expanded="(open$local) ?? undefined" :aria-controls="props.id + '-list'" :aria-activedescendant="(activeDescendant) ?? undefined" :aria-label="props.ariaLabel" :disabled="props.disabled" @click="toggle" @keydown="onControlKeyDown($event)">
<slot name="selected" :selected="selectedLabel" :value="value">
<span v-if="selectedLabel" class="rozie-listbox-selected">{{ selectedLabel }}</span><span v-else class="rozie-listbox-placeholder">{{ props.placeholder }}</span></slot>
<span class="rozie-listbox-arrow" aria-hidden="true">▾</span>
</button>
</div>
<div v-if="open$local && !props.virtual" ref="listElRef" class="rozie-listbox-list" role="listbox" :id="props.id + '-list'" :aria-label="props.ariaLabel" :aria-multiselectable="props.multiple">
<div v-for="(opt, index) in visibleOptions()" :key="optionId(index)" :id="optionId(index)" :class="['rozie-listbox-option', { 'is-active': activeIndex === index, 'is-selected': isSelected(opt), 'is-disabled': disabledOf(opt) }]" role="option" :aria-selected="!!isSelected(opt)" :aria-disabled="!!disabledOf(opt)" @click="select(opt)" @mousemove="onOptionPointerMove(index)">
<slot name="option" :option="opt" :index="index" :active="activeIndex === index" :selected="isSelected(opt)" :disabled="disabledOf(opt)">
{{ labelOf(opt) }}
</slot>
</div>
<div v-if="visibleOptions().length === 0" class="rozie-listbox-empty" role="presentation">
<slot name="empty" :query="query">No options</slot>
</div></div><div v-if="props.virtual" ref="listElRef" class="rozie-listbox-list rozie-listbox-list--virtual" role="listbox" :id="props.id + '-list'" :aria-label="props.ariaLabel" :aria-multiselectable="props.multiple" :style="(open$local ? '' : 'display:none;') + (props.maxHeight ? 'height:' + props.maxHeight + ';max-height:' + props.maxHeight + ';overflow-y:auto;--rozie-listbox-max-height:' + props.maxHeight : 'overflow-y:auto')">
<div class="rozie-listbox-spacer" aria-hidden="true" :style="'height:' + padTop() + 'px'"></div>
<div v-for="wr in windowedRows()" :key="wr.row.id" :id="optionId(wr.vi.index)" :data-index="wr.vi.index" :class="['rozie-listbox-option', { 'is-active': activeIndex === wr.vi.index, 'is-selected': isSelected(wr.row._opt), 'is-disabled': disabledOf(wr.row._opt) }]" role="option" :aria-selected="!!isSelected(wr.row._opt)" :aria-disabled="!!disabledOf(wr.row._opt)" @click="select(wr.row._opt)" @mousemove="onOptionPointerMove(wr.vi.index)">
<slot name="option" :option="wr.row._opt" :index="wr.vi.index" :active="activeIndex === wr.vi.index" :selected="isSelected(wr.row._opt)" :disabled="disabledOf(wr.row._opt)">
{{ labelOf(wr.row._opt) }}
</slot>
</div>
<div class="rozie-listbox-spacer" aria-hidden="true" :style="'height:' + padBottom() + 'px'"></div>
<div v-if="windowSource().length === 0" class="rozie-listbox-empty" role="presentation">
<slot name="empty" :query="query">No options</slot>
</div></div></div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useOutsideClick } from '@rozie/runtime-vue';
const props = withDefaults(
defineProps<{
/**
* The option set. Each entry is either a primitive (`string`/`number`) or an object; objects resolve their label, value, and disabled state via the `option*` resolver props, falling back to `.label` / `.value` / `.disabled`.
*/
options?: any[];
/**
* Enable multi-select: `value` becomes an array, selecting an option toggles its membership, and the popup stays open after each commit.
*/
multiple?: boolean;
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the listbox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
inline?: boolean;
/**
* Disable the control entirely. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Placeholder text shown in the empty control.
*/
placeholder?: string;
/**
* Close the popup after a single-select commit. Defaults `true`; multi-select keeps the popup open regardless of this setting.
*/
closeOnSelect?: boolean;
/**
* Resolver override for an object option's display label — `(option) => string`. Falls back to the option's `.label` property.
*/
optionLabel?: ((...args: any[]) => any) | null;
/**
* Resolver override for an object option's committed value — `(option) => value`. Falls back to the option's `.value` property.
*/
optionValue?: ((...args: any[]) => any) | null;
/**
* Resolver override marking an option non-selectable — `(option) => boolean`. Falls back to the option's `.disabled` property.
*/
optionDisabled?: ((...args: any[]) => any) | null;
/**
* Stable id base for the ARIA wiring (the listbox id, per-option ids, and `aria-activedescendant`). Give each instance on a page a distinct id so these references stay unique.
*/
id?: string;
/**
* Accessible name for the control when there is no visible `<label for>` pointing at its `id` (`aria-label`).
*/
ariaLabel?: string | null;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling list (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed listbox. Pair with `inline` + `maxHeight` so the windowed scroll container is bounded.
*/
virtual?: boolean;
/**
* Estimated option row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight?: number;
/**
* A CSS length string bounding the list scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-listbox-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight?: string;
}>(),
{ options: () => [], multiple: false, inline: false, disabled: false, placeholder: '', closeOnSelect: true, optionLabel: null, optionValue: null, optionDisabled: null, id: 'rozie-listbox', ariaLabel: null, virtual: false, estimateRowHeight: 36, maxHeight: '' }
);
/**
* The selected value (two-way `r-model`) — a scalar in single-select, an array of values in multi-select. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Listbox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
* @example
* <Listbox r-model:value="fruit" :options="fruits" />
*/
const value = defineModel<unknown>('value', { default: null });
const emit = defineEmits<{
'open-change': [...args: any[]];
change: [...args: any[]];
}>();
defineSlots<{
selected(props: { selected: any; value: any }): any;
option(props: { option: any; index: any; active: any; selected: any; disabled: any }): any;
empty(props: { query: any }): any;
option(props: { option: any; index: any; active: any; selected: any; disabled: any }): any;
empty(props: { query: any }): any;
}>();
const open$local = ref(false);
const activeIndex = ref(-1);
const query = ref('');
const rows = ref<any[]>([]);
const windowVer = ref(0);
const editVer = ref(0);
const controlElRef = ref<HTMLElement>();
const triggerElRef = ref<HTMLButtonElement>();
const listElRef = ref<HTMLElement>();
const __rozieRootRef = ref<HTMLElement>();
const selectedLabel = computed(() => {
const cur = value.value;
if (props.multiple) {
// Read the model value into a local before narrowing: `$props.value` lowers
// to a `value()` accessor on Solid, and Array.isArray() can't narrow two
// separate calls — narrowing one stable local works on every target.
const arr = Array.isArray(cur) ? cur : [];
if (arr.length === 0) return '';
return props.options.filter((o: any) => arr.includes(valueOf(o))).map(labelOf).join(', ');
}
const match = props.options.find((o: any) => valueOf(o) === cur);
return match === undefined ? '' : labelOf(match);
});
const activeDescendant = computed(() => {
if (!open$local.value || activeIndex.value < 0) return null;
return optionId(activeIndex.value);
});
// Type-ahead buffer for the select-only listbox trigger. Module-scope
// `let`s reassigned from handlers → the React emitter hoists them to `useRef`
// so they persist across renders (the setup-once guarantee); no-op elsewhere.
// They STAY in this host (not the shared spine) per the A==B rule: reassigned
// module-`let`s + sigils live in the host; the partial only closes over them.
let typeBuffer = '';
let typeTimer: any = null;
// ---- shared list spine (P2: @rozie-ui/headless-core/listCore.rzts) ------
// The option resolvers, client filter, enabled-index navigation, the keyboard
// reducer, type-ahead, single+multi selection, open/close state, and
// activeDescendant derivation now live in the shared, focus-/input-mode
// parameterized list spine. It is a compile-time `.rzts` script-partial: it
// dissolves into this leaf via inlineScriptPartials() before IR lowering (zero
// runtime dep). Listbox consumes it in focus-model `activedescendant` +
// input-mode `select-only` + multi + type-ahead. The spine closes over this
// host's pieces by convention: the reassigned module-`let`s typeBuffer/typeTimer
// (above) and the impure ref fns focusControl/scrollActiveIntoView (below).
// ══ Shared headless LIST SPINE (Phase 64, D-06) — the target-agnostic list-core bridge ══
// Lifted verbatim from Listbox.rozie's <script> (the monolithic pure-Rozie list logic). This
// partial holds ONLY the PURE list spine — option resolvers, the client-side filter, enabled-index
// navigation, the arrow/home/end/enter/escape/space/tab keyboard reducer, type-ahead, single+multi
// selection, open/close state, and activeDescendant derivation. It is a compile-time `.rzts`
// script-partial: it dissolves into each consumer's compiled leaf via inlineScriptPartials() before
// IR lowering — leaving zero runtime dependency (the 64-01-proven cross-package bare-specifier path).
//
// ── PARAMETERIZATION (D-06) ──────────────────────────────────────────────────────────────────
// The spine is parameterized BY HOST CONVENTION (the same implicit by-convention mixin contract
// windowing.rzts uses) along two axes:
// - focus-model: `activedescendant` | `roving`. Both list families default to `activedescendant`
// (what they use today): the highlighted option is tracked virtually via `activeDescendant`
// (an option id) while DOM focus stays on the control. `roving` (real per-option tabindex
// focus) is SUPPORTED-BUT-UNUSED — no focus rewrite is forced here; a roving host would supply
// its own focus mover. The `activeDescendant` / `optionId` derivation below IS the
// activedescendant model.
// - input-mode: `select-only` (Listbox — a button trigger + type-ahead) | `filter-input`
// (Combobox — a text <input> that filters by the typed query). The mode is by HOST CONVENTION,
// NOT a discriminant prop (P3 retired the Listbox `combobox`/`filterable` props): a select-only
// host never writes `$data.query`, so `visibleOptions` is the identity path for it and the
// printable-char branch of the reducer feeds type-ahead; a filter-input host writes `$data.query`
// from its <input>, so `visibleOptions` substring-filters and `onInput` drives the query.
//
// ── HOST CONTRACT (symbols the consuming host MUST define before importing) ────────────────────
// - the reassigned module-`let`s `typeBuffer` / `typeTimer` — type-ahead scratch state. They are
// reassigned from handlers → the React emitter hoists them to `useRef` (the setup-once
// guarantee), so per the A==B playbook rule they STAY IN THE HOST; this partial only closes
// over them (in `onTypeahead`).
// - `focusControl()` / `scrollActiveIntoView()` — impure ref-reading functions (they touch the
// control / list ref elements, which are post-mount-only per ROZ123), so they are per-consumer
// HOST functions; this partial only closes over them (it reads NO refs itself).
// - the option set + form surface (`$props.options` / `$props.value` (model) / `$props.multiple` /
// `$props.id` / `$props.optionLabel` / `$props.optionValue` / `$props.optionDisabled` /
// `$props.closeOnSelect` / `$props.disabled`) and the reactive state (`$data.open` /
// `$data.activeIndex` / `$data.query`). Input-mode is by convention (the host's <input> writing
// `$data.query`), NOT a discriminant prop.
// ---- option resolvers --------------------------------------------------
// ══ Shared headless LIST SPINE (Phase 64, D-06) — the target-agnostic list-core bridge ══
// Lifted verbatim from Listbox.rozie's <script> (the monolithic pure-Rozie list logic). This
// partial holds ONLY the PURE list spine — option resolvers, the client-side filter, enabled-index
// navigation, the arrow/home/end/enter/escape/space/tab keyboard reducer, type-ahead, single+multi
// selection, open/close state, and activeDescendant derivation. It is a compile-time `.rzts`
// script-partial: it dissolves into each consumer's compiled leaf via inlineScriptPartials() before
// IR lowering — leaving zero runtime dependency (the 64-01-proven cross-package bare-specifier path).
//
// ── PARAMETERIZATION (D-06) ──────────────────────────────────────────────────────────────────
// The spine is parameterized BY HOST CONVENTION (the same implicit by-convention mixin contract
// windowing.rzts uses) along two axes:
// - focus-model: `activedescendant` | `roving`. Both list families default to `activedescendant`
// (what they use today): the highlighted option is tracked virtually via `activeDescendant`
// (an option id) while DOM focus stays on the control. `roving` (real per-option tabindex
// focus) is SUPPORTED-BUT-UNUSED — no focus rewrite is forced here; a roving host would supply
// its own focus mover. The `activeDescendant` / `optionId` derivation below IS the
// activedescendant model.
// - input-mode: `select-only` (Listbox — a button trigger + type-ahead) | `filter-input`
// (Combobox — a text <input> that filters by the typed query). The mode is by HOST CONVENTION,
// NOT a discriminant prop (P3 retired the Listbox `combobox`/`filterable` props): a select-only
// host never writes `$data.query`, so `visibleOptions` is the identity path for it and the
// printable-char branch of the reducer feeds type-ahead; a filter-input host writes `$data.query`
// from its <input>, so `visibleOptions` substring-filters and `onInput` drives the query.
//
// ── HOST CONTRACT (symbols the consuming host MUST define before importing) ────────────────────
// - the reassigned module-`let`s `typeBuffer` / `typeTimer` — type-ahead scratch state. They are
// reassigned from handlers → the React emitter hoists them to `useRef` (the setup-once
// guarantee), so per the A==B playbook rule they STAY IN THE HOST; this partial only closes
// over them (in `onTypeahead`).
// - `focusControl()` / `scrollActiveIntoView()` — impure ref-reading functions (they touch the
// control / list ref elements, which are post-mount-only per ROZ123), so they are per-consumer
// HOST functions; this partial only closes over them (it reads NO refs itself).
// - the option set + form surface (`$props.options` / `$props.value` (model) / `$props.multiple` /
// `$props.id` / `$props.optionLabel` / `$props.optionValue` / `$props.optionDisabled` /
// `$props.closeOnSelect` / `$props.disabled`) and the reactive state (`$data.open` /
// `$data.activeIndex` / `$data.query`). Input-mode is by convention (the host's <input> writing
// `$data.query`), NOT a discriminant prop.
// ---- option resolvers --------------------------------------------------
const labelOf = (opt: any) => {
if (props.optionLabel !== null) return props.optionLabel(opt);
if (opt !== null && typeof opt === 'object' && 'label' in opt) return opt.label;
return String(opt);
};
const valueOf = (opt: any) => {
if (props.optionValue !== null) return props.optionValue(opt);
if (opt !== null && typeof opt === 'object' && 'value' in opt) return opt.value;
return opt;
};
const disabledOf = (opt: any) => {
if (props.optionDisabled !== null) return !!props.optionDisabled(opt);
if (opt !== null && typeof opt === 'object' && 'disabled' in opt) return !!opt.disabled;
return false;
};
const optionId = (index: any) => props.id + '-opt-' + index;
// ---- derived state -----------------------------------------------------
// The visible option list: identity in select-only / non-filtering mode,
// a case-insensitive substring filter when a combobox query is present.
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — a `$computed` is a value on React but an accessor on Solid, so
// aliasing it to a local (`const opts = visibleOptions()`) diverges; calling a
// plain function is identical everywhere.
// ---- derived state -----------------------------------------------------
// The visible option list: identity in select-only / non-filtering mode,
// a case-insensitive substring filter when a combobox query is present.
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — a `$computed` is a value on React but an accessor on Solid, so
// aliasing it to a local (`const opts = visibleOptions()`) diverges; calling a
// plain function is identical everywhere.
const visibleOptions = () => {
const q = (query.value || '').trim().toLowerCase();
if (q === '') return props.options;
return props.options.filter((opt: any) => labelOf(opt).toLowerCase().includes(q));
};
// The label shown in the (select-only) trigger when closed. A real `$computed`
// — read bare in the template, never aliased in script, so the per-target
// accessor form stays uniform.
// Is a given option currently selected? Multi compares array membership.
// Is a given option currently selected? Multi compares array membership.
const isSelected = (opt: any) => {
const v = valueOf(opt);
const cur = value.value;
if (props.multiple) return Array.isArray(cur) && cur.includes(v);
return cur === v;
};
// First enabled visible index, preferring the currently-selected option.
// First enabled visible index, preferring the currently-selected option.
const resolveInitialActive = () => {
const opts = visibleOptions();
const sel = opts.findIndex((o: any) => isSelected(o) && !disabledOf(o));
if (sel !== -1) return sel;
return opts.findIndex((o: any) => !disabledOf(o));
};
// ---- open / close ------------------------------------------------------
// Single open-state mutator → the ONLY `$emit('open-change')` site, so the
// React prop-destructure for `onOpenChange` hoists exactly once.
// ---- open / close ------------------------------------------------------
// Single open-state mutator → the ONLY `$emit('open-change')` site, so the
// React prop-destructure for `onOpenChange` hoists exactly once.
const applyExpanded = (next: any) => {
if (next && props.disabled) return;
if (open$local.value === next) return;
open$local.value = next;
activeIndex.value = next ? resolveInitialActive() : -1;
emit('open-change', {
open: next
});
};
const open = () => applyExpanded(true);
const close = () => applyExpanded(false);
const toggle = () => applyExpanded(!open$local.value);
// ---- selection ---------------------------------------------------------
// Single `$emit('change')` site (called from both select + clear).
// ---- selection ---------------------------------------------------------
// Single `$emit('change')` site (called from both select + clear).
const fireChange = (value: any, option: any) => emit('change', {
value,
option
});
const select = (opt: any) => {
if (disabledOf(opt)) return;
const v = valueOf(opt);
if (props.multiple) {
const cur = value.value;
const arr = Array.isArray(cur) ? cur : [];
// Fresh array on every commit — in-place mutation is dropped by the
// React/Solid/Lit/Angular change detectors.
const next = arr.includes(v) ? arr.filter((x: any) => x !== v) : [...arr, v];
value.value = next;
fireChange(next, opt);
} else {
value.value = v;
fireChange(v, opt);
if (props.closeOnSelect) {
close();
focusControl();
}
}
};
const clear = () => {
const empty = props.multiple ? [] : null;
value.value = empty;
query.value = '';
fireChange(empty, null);
};
// ---- keyboard navigation over the VISIBLE list -------------------------
// ---- keyboard navigation over the VISIBLE list -------------------------
const nextEnabled = (from: any, dir: any) => {
const opts = visibleOptions();
if (opts.length === 0) return -1;
let i = from;
for (let step = 0; step < opts.length; step++) {
i += dir;
if (i < 0) i = opts.length - 1;else if (i >= opts.length) i = 0;
if (!disabledOf(opts[i])) return i;
}
return from;
};
const move = (dir: any) => {
if (!open$local.value) {
open();
return;
}
const start = activeIndex.value < 0 ? dir > 0 ? -1 : 0 : activeIndex.value;
activeIndex.value = nextEnabled(start, dir);
scrollActiveIntoView();
};
const moveEdge = (toEnd: any) => {
if (!open$local.value) open();
activeIndex.value = toEnd ? nextEnabled(-1, -1) : nextEnabled(-1, 1);
scrollActiveIntoView();
};
const commitActive = () => {
const opts = visibleOptions();
if (activeIndex.value >= 0 && activeIndex.value < opts.length) select(opts[activeIndex.value]);
};
// Type-ahead for select-only listboxes: accumulate keystrokes and jump to the
// first option whose label starts with the buffer.
// Type-ahead for select-only listboxes: accumulate keystrokes and jump to the
// first option whose label starts with the buffer.
const onTypeahead = (ch: any) => {
if (typeTimer !== null) clearTimeout(typeTimer);
typeBuffer += ch.toLowerCase();
typeTimer = setTimeout(() => {
typeBuffer = '';
}, 600);
const opts = visibleOptions();
const idx = opts.findIndex((o: any) => !disabledOf(o) && labelOf(o).toLowerCase().startsWith(typeBuffer));
if (idx !== -1) {
if (!open$local.value) open();
activeIndex.value = idx;
scrollActiveIntoView();
}
};
// Key handler shared by the trigger and the combobox input. The printable-
// character branch is reached only in select-only mode (the combobox input
// types through @input).
// Key handler shared by the trigger and the combobox input. The printable-
// character branch is reached only in select-only mode (the combobox input
// types through @input).
const onControlKeyDown = ($event: any) => {
const key = $event.key;
if (key === 'ArrowDown') {
$event.preventDefault();
move(1);
} else if (key === 'ArrowUp') {
$event.preventDefault();
move(-1);
} else if (key === 'Home') {
$event.preventDefault();
moveEdge(false);
} else if (key === 'End') {
$event.preventDefault();
moveEdge(true);
} else if (key === 'Enter') {
if (open$local.value) {
$event.preventDefault();
commitActive();
}
} else if (key === 'Escape') {
if (open$local.value) {
$event.preventDefault();
close();
focusControl();
}
} else if (key === ' ' || key === 'Spacebar') {
// Space toggles / commits in a select-only host (a button trigger). A
// filter-input host types the literal space into its <input> and does NOT
// route Space through this reducer, so this branch is select-only by use.
$event.preventDefault();
if (!open$local.value) open();else commitActive();
} else if (key === 'Tab') {
if (open$local.value) close();
} else if (key.length === 1 && !$event.metaKey && !$event.ctrlKey && !$event.altKey) {
onTypeahead(key);
}
};
// Combobox input handler: keep the popup open while typing, reset the active
// highlight to the first match, and surface the query for remote filtering.
// Pointer hover sets the virtual highlight (matches native <select> feel).
// Pointer hover sets the virtual highlight (matches native <select> feel).
const onOptionPointerMove = (index: any) => {
if (activeIndex.value !== index) activeIndex.value = index;
};
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
const virtualItemKey = (i: any) => {
const src = windowSource();
return src && src[i] ? src[i].id : undefined;
};
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
const virtualizerOptions = (): any => ({
count: windowSource().length,
getScrollElement: () => gridScrollEl,
estimateSize: () => props.estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
windowVer.value = windowVer.value + 1;
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
scheduleRemeasure();
}
});
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
const pinMeasurement = (pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null => pinnedMeasurement(pin);
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
const windowedRows = () => {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void windowVer.value;
void editVer.value;
if (!virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!props.virtual) {
const rowList = rows.value || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = virtualizer.getVirtualItems();
const rowList = rows.value || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
};
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
const padTop = () => {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void windowVer.value;
void editVer.value;
if (!props.virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
};
const padBottom = () => {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void windowVer.value;
void editVer.value;
if (!props.virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
};
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
const pmIndexInWindow = (items: any, idx: any) => {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
};
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
const rowIsOutsideWindow = (r: any) => {
if (!props.virtual || !virtualizer) return false;
const items = virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally
// (a peer dep); every RUNTIME reference sits behind `if ($props.virtual)` / a
// `virtualizer` guard so the non-virtual emitted path executes none of it
// (byte-identical-off).
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally
// (a peer dep); every RUNTIME reference sits behind `if ($props.virtual)` / a
// `virtualizer` guard so the non-virtual emitted path executes none of it
// (byte-identical-off).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
let remeasurePending = false;
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// set. CR-02: the shared windowing contract requires each row to carry a STABLE `.id`
// (windowing.rzts virtualItemKey reads src[i].id, and the windowed template keys on
// wr.row.id). A raw Listbox option is a primitive or a bare { label, value, disabled }
// — NOT guaranteed to have `.id` — so an unwrapped raw set keyed on wr.row.id collapses
// every framework :key (and every virtual-core measurement key) to `undefined`, which
// recycles the wrong DOM node as the window scrolls. Wrap each option into an id-bearing
// row the way the sibling Combobox's filteredOptions() does — `id` is the resolved
// value, `_opt` the original option (read via wr.row._opt in the windowed template),
// `_i` the source index. Kept === $data.rows so the math's rowList[vi.index] resolves to
// the same wrapped row the count windows over.
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// set. CR-02: the shared windowing contract requires each row to carry a STABLE `.id`
// (windowing.rzts virtualItemKey reads src[i].id, and the windowed template keys on
// wr.row.id). A raw Listbox option is a primitive or a bare { label, value, disabled }
// — NOT guaranteed to have `.id` — so an unwrapped raw set keyed on wr.row.id collapses
// every framework :key (and every virtual-core measurement key) to `undefined`, which
// recycles the wrong DOM node as the window scrolls. Wrap each option into an id-bearing
// row the way the sibling Combobox's filteredOptions() does — `id` is the resolved
// value, `_opt` the original option (read via wr.row._opt in the windowed template),
// `_i` the source index. Kept === $data.rows so the math's rowList[vi.index] resolves to
// the same wrapped row the count windows over.
const windowSource = () => visibleOptions().map((o: any, i: any) => ({
id: valueOf(o),
_opt: o,
_i: i
}));
// D-05 NO-OP PIN HOOK (defined in THIS host, NOT the shared partial — keeps data-table
// A==B intact). The shared windowedRows/padTop/padBottom call pinnedEditIndex()/
// pinnedMeasurement() UNGUARDED by convention; a listbox has no edit-pinning, so these
// reduce the pin union (-1 → never unioned) and the spacer subtraction (null → identity)
// to a no-op. They MUST exist or the by-convention call ReferenceErrors at mount.
// D-05 NO-OP PIN HOOK (defined in THIS host, NOT the shared partial — keeps data-table
// A==B intact). The shared windowedRows/padTop/padBottom call pinnedEditIndex()/
// pinnedMeasurement() UNGUARDED by convention; a listbox has no edit-pinning, so these
// reduce the pin union (-1 → never unioned) and the spacer subtraction (null → identity)
// to a no-op. They MUST exist or the by-convention call ReferenceErrors at mount.
const pinnedEditIndex = () => -1;
const pinnedMeasurement = (pin: any) => null;
// Keep $data.rows === windowSource() so the windowing math indexes the live option set.
// Keep $data.rows === windowSource() so the windowing math indexes the live option set.
const syncRows = () => {
rows.value = windowSource();
};
// Defer remeasureWindow() until AFTER the framework commits the recycled window
// (onChange fires BEFORE React/Solid commit). TWO deferred passes (microtask THEN rAF)
// behind one in-flight flag (the data-table virtualization.rzts:46-56 pattern, copied
// per-consumer per D-04/D-09): the microtask catches Solid's <For> / Svelte's {#each}
// SYNCHRONOUS commit (the Phase 63 Solid under-convergence hazard — D-09 rAF-defer
// budget), the rAF catches React's async commit. measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
// Defer remeasureWindow() until AFTER the framework commits the recycled window
// (onChange fires BEFORE React/Solid commit). TWO deferred passes (microtask THEN rAF)
// behind one in-flight flag (the data-table virtualization.rzts:46-56 pattern, copied
// per-consumer per D-04/D-09): the microtask catches Solid's <For> / Svelte's {#each}
// SYNCHRONOUS commit (the Phase 63 Solid under-convergence hazard — D-09 rAF-defer
// budget), the rAF catches React's async commit. measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
const scheduleRemeasure = () => {
if (remeasurePending) return;
remeasurePending = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
const rafPass = () => {
remeasurePending = false;
remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
};
// measureElement sweep: hand every rendered windowed option to the virtualizer so its
// true (variable) height is observed (virtual-core measures ONLY nodes passed to
// measureElement, keyed by the data-index attribute). Bails during a programmatic
// scroll (scrollToIndex) so a measure can't starve the scroll target.
// measureElement sweep: hand every rendered windowed option to the virtualizer so its
// true (variable) height is observed (virtual-core measures ONLY nodes passed to
// measureElement, keyed by the data-index attribute). Bails during a programmatic
// scroll (scrollToIndex) so a measure can't starve the scroll target.
const remeasureWindow = () => {
if (!virtualizer || !gridScrollEl) return;
if (virtualizer.scrollState) return;
const els = gridScrollEl.querySelectorAll('.rozie-listbox-option[data-index]');
for (const el of els as any) virtualizer.measureElement(el);
};
// ---- focus / scroll helpers (post-mount $refs only) --------------------
// Impure ($refs) → per the ROZ123 + A==B rules they stay in the host (the spine
// only closes over them). Named `focusControl` (not `focus`): a `focus` $expose
// verb would override the inherited HTMLElement.focus method on the Lit element.
// ---- focus / scroll helpers (post-mount $refs only) --------------------
// Impure ($refs) → per the ROZ123 + A==B rules they stay in the host (the spine
// only closes over them). Named `focusControl` (not `focus`): a `focus` $expose
// verb would override the inherited HTMLElement.focus method on the Lit element.
const focusControl = () => {
triggerElRef.value?.focus();
};
// Keep the active option visible inside the scrolling listbox. Reads $refs in
// a post-mount callback only (never eagerly — ROZ123). When windowing, route through
// the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window is
// scrolled into view (the windowed-arrow-nav seam); else the native scrollIntoView.
// Keep the active option visible inside the scrolling listbox. Reads $refs in
// a post-mount callback only (never eagerly — ROZ123). When windowing, route through
// the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window is
// scrolled into view (the windowed-arrow-nav seam); else the native scrollIntoView.
const scrollActiveIntoView = () => {
if (activeIndex.value < 0) return;
if (props.virtual && virtualizer) {
// 'center' (not 'auto'): keep the active option well inside the rendered slice as the
// window scrolls — 'auto' lands it at the viewport edge where the overscan band can
// leave it just-unrendered for a frame on the fine-grained targets (Solid).
virtualizer.scrollToIndex(activeIndex.value, {
align: 'center'
});
scheduleRemeasure();
return;
}
if (!listElRef.value) return;
const el = listElRef.value!.querySelector('#' + CSS.escape(optionId(activeIndex.value)));
el?.scrollIntoView({
block: 'nearest'
});
};
// ---- windowing lifecycle (post-mount; ONLY when virtual) ----------------
// kickWindow: the cross-target first-paint settle. Re-captures the LIVE scroll element,
// re-feeds the CURRENT option count into the virtualizer, re-attaches its rect observer
// (_willUpdate), and bumps the windowVer signal so the windowed <For>/{#each}/repeat
// re-derives. Retried over a few frames because (a) virtual-core measures the scroll rect
// asynchronously (D-09 Solid rAF-defer — a synchronous kick sees rectH 0 → empty window),
// (b) Solid/Lit recreate the list node between mount and first commit (leaving virtual-core's
// scrollElement stale), and (c) the consumer often seeds options AFTER the listbox mounts
// (Lit/React), so the count must be re-read once the prop propagates. Stops once the window
// paints (or attempts run out) — idempotent + loop-free.
// ---- windowing lifecycle (post-mount; ONLY when virtual) ----------------
// kickWindow: the cross-target first-paint settle. Re-captures the LIVE scroll element,
// re-feeds the CURRENT option count into the virtualizer, re-attaches its rect observer
// (_willUpdate), and bumps the windowVer signal so the windowed <For>/{#each}/repeat
// re-derives. Retried over a few frames because (a) virtual-core measures the scroll rect
// asynchronously (D-09 Solid rAF-defer — a synchronous kick sees rectH 0 → empty window),
// (b) Solid/Lit recreate the list node between mount and first commit (leaving virtual-core's
// scrollElement stale), and (c) the consumer often seeds options AFTER the listbox mounts
// (Lit/React), so the count must be re-read once the prop propagates. Stops once the window
// paints (or attempts run out) — idempotent + loop-free.
const kickWindow = (attempts: any) => {
if (!virtualizer) return;
gridScrollEl = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rozie-listbox-list') : gridScrollEl;
// Only re-feed the count from a NON-EMPTY source: on React these rAF closures capture
// stale (mount-time, empty) props, so feeding here would CLOBBER the $watch's correct
// count back to 0. The $watch (fresh useEffect props) owns React's count; the kick owns
// the Solid/Lit scroll-element re-attach + the deferred windowVer re-derive.
if (windowSource().length > 0) {
syncRows();
virtualizer.setOptions(virtualizerOptions());
}
virtualizer._willUpdate();
windowVer.value = windowVer.value + 1;
remeasureWindow();
if (windowedRows().length === 0 && attempts > 0) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(attempts - 1));else setTimeout(() => kickWindow(attempts - 1), 16);
}
};
onMounted(() => {
syncRows();
if (props.virtual) {
// The list renders at mount when virtual, so the .rozie-listbox-list scroll container
// exists here. Capture it via $el.querySelector (the data-table gridScrollEl precedent,
// proven ×6 incl Lit shadow + Solid) — $refs on a conditionally-rendered node is null on
// Solid/Lit, which leaves the virtualizer with no scroll element.
gridScrollEl = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rozie-listbox-list') : null;
virtualizer = new Virtualizer(virtualizerOptions());
virtualizerCleanup = virtualizer._didMount();
windowVer.value = windowVer.value + 1;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(8));else setTimeout(() => kickWindow(8), 0);
}
});
onBeforeUnmount(() => {
if (typeTimer !== null) clearTimeout(typeTimer);
// Tear down the virtualizer's scroll-element ResizeObserver (no-op when virtual off).
if (virtualizerCleanup) virtualizerCleanup();
});
watch(() => (props.options ? props.options.length : 0) + '|' + query.value, () => {
syncRows();
if (props.virtual && virtualizer) {
gridScrollEl = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rozie-listbox-list') : gridScrollEl;
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
windowVer.value = windowVer.value + 1;
scheduleRemeasure();
}
});
defineExpose({ open, close, toggle, clear, focusControl });
useOutsideClick(
[controlElRef, listElRef],
() => close(),
() => open$local.value,
);
</script>
<style scoped>
.rozie-listbox {
position: relative;
display: inline-block;
min-width: var(--rozie-listbox-min-width, 12rem);
font: var(--rozie-listbox-font, inherit);
}
.rozie-listbox-control { display: block; }
.rozie-listbox-input,
.rozie-listbox-trigger {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
padding: var(--rozie-listbox-control-padding, 0.5rem 0.75rem);
font: inherit;
text-align: left;
background: var(--rozie-listbox-bg, #fff);
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-border, rgba(0, 0, 0, 0.2));
border-radius: var(--rozie-listbox-radius, 6px);
cursor: pointer;
}
.rozie-listbox-input { cursor: text; }
.rozie-listbox-input:focus-visible,
.rozie-listbox-input:focus,
.rozie-listbox-trigger:focus-visible,
.rozie-listbox-trigger:focus {
outline: var(--rozie-listbox-ring-width, 2px) solid var(--rozie-listbox-ring, var(--rozie-listbox-accent, #0066cc));
outline-offset: var(--rozie-listbox-ring-offset, 1px);
}
.rozie-listbox-disabled { opacity: var(--rozie-listbox-disabled-opacity, 0.6); pointer-events: none; }
.rozie-listbox-placeholder { color: var(--rozie-listbox-placeholder, rgba(0, 0, 0, 0.45)); }
.rozie-listbox-arrow {
font-size: 0.75em;
color: var(--rozie-listbox-arrow-color, currentColor);
opacity: var(--rozie-listbox-arrow-opacity, 0.7);
}
.rozie-listbox-list {
position: absolute;
z-index: var(--rozie-listbox-z, 1000);
top: calc(100% + var(--rozie-listbox-popup-offset, 4px));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-listbox-popup-padding, 0.25rem);
max-height: var(--rozie-listbox-max-height, 16rem);
overflow-y: auto;
list-style: none;
background: var(--rozie-listbox-popup-bg, var(--rozie-listbox-bg, #fff));
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-popup-border, var(--rozie-listbox-border, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-listbox-popup-radius, var(--rozie-listbox-radius, 6px));
box-shadow: var(--rozie-listbox-shadow, 0 6px 24px rgba(0, 0, 0, 0.12));
}
.rozie-listbox-inline {
display: block;
width: 100%;
}
.rozie-listbox-inline .rozie-listbox-list {
position: static;
margin-top: var(--rozie-listbox-popup-offset, 4px);
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-listbox-option {
padding: var(--rozie-listbox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-listbox-option-radius, 4px);
color: var(--rozie-listbox-option-fg, inherit);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
}
.rozie-listbox-option.is-active {
background: var(--rozie-listbox-active-bg, rgba(0, 102, 204, 0.12));
color: var(--rozie-listbox-active-fg, inherit);
}
.rozie-listbox-option.is-selected {
background: var(--rozie-listbox-selected-bg, transparent);
color: var(--rozie-listbox-selected-fg, inherit);
font-weight: var(--rozie-listbox-selected-weight, 600);
}
.rozie-listbox-option.is-selected::after {
content: var(--rozie-listbox-check, '✓');
color: var(--rozie-listbox-check-color, var(--rozie-listbox-accent, #0066cc));
}
.rozie-listbox-option.is-disabled { opacity: var(--rozie-listbox-disabled-opacity, 0.45); cursor: not-allowed; }
.rozie-listbox-empty { padding: var(--rozie-listbox-option-padding, 0.5rem 0.6rem); color: var(--rozie-listbox-empty-fg, rgba(0, 0, 0, 0.5)); }
.rozie-listbox-spacer { margin: 0; padding: 0; border: 0; flex: none; }
</style>svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieDisplay, rozieStyle } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
interface Props {
/**
* The option set. Each entry is either a primitive (`string`/`number`) or an object; objects resolve their label, value, and disabled state via the `option*` resolver props, falling back to `.label` / `.value` / `.disabled`.
*/
options?: any[];
/**
* The selected value (two-way `r-model`) — a scalar in single-select, an array of values in multi-select. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Listbox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
* @example
* <Listbox r-model:value="fruit" :options="fruits" />
*/
value?: (unknown) | null;
/**
* Enable multi-select: `value` becomes an array, selecting an option toggles its membership, and the popup stays open after each commit.
*/
multiple?: boolean;
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the listbox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
inline?: boolean;
/**
* Disable the control entirely. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Placeholder text shown in the empty control.
*/
placeholder?: string;
/**
* Close the popup after a single-select commit. Defaults `true`; multi-select keeps the popup open regardless of this setting.
*/
closeOnSelect?: boolean;
/**
* Resolver override for an object option's display label — `(option) => string`. Falls back to the option's `.label` property.
*/
optionLabel?: ((...args: any[]) => any) | null;
/**
* Resolver override for an object option's committed value — `(option) => value`. Falls back to the option's `.value` property.
*/
optionValue?: ((...args: any[]) => any) | null;
/**
* Resolver override marking an option non-selectable — `(option) => boolean`. Falls back to the option's `.disabled` property.
*/
optionDisabled?: ((...args: any[]) => any) | null;
/**
* Stable id base for the ARIA wiring (the listbox id, per-option ids, and `aria-activedescendant`). Give each instance on a page a distinct id so these references stay unique.
*/
id?: string;
/**
* Accessible name for the control when there is no visible `<label for>` pointing at its `id` (`aria-label`).
*/
ariaLabel?: (string) | null;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling list (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed listbox. Pair with `inline` + `maxHeight` so the windowed scroll container is bounded.
*/
virtual?: boolean;
/**
* Estimated option row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight?: number;
/**
* A CSS length string bounding the list scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-listbox-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight?: string;
selected?: Snippet<[{ selected: any; value: any }]>;
option?: Snippet<[{ option: any; index: any; active: any; selected: any; disabled: any }]>;
empty?: Snippet<[{ query: any }]>;
snippets?: Record<string, any>;
onopenchange?: (...args: unknown[]) => void;
onchange?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let __defaultOptions = (() => [])();
let {
options = __defaultOptions,
value = $bindable(null),
multiple = false,
inline = false,
disabled = false,
placeholder = '',
closeOnSelect = true,
optionLabel = null,
optionValue = null,
optionDisabled = null,
id = 'rozie-listbox',
ariaLabel = null,
virtual = false,
estimateRowHeight = 36,
maxHeight = '',
selected: __selectedProp,
option: __optionProp,
empty: __emptyProp,
snippets,
onopenchange,
onchange,
...__rozieAttrs
}: Props = $props();
const selected = $derived(__selectedProp ?? snippets?.selected);
const option = $derived(__optionProp ?? snippets?.option);
const empty = $derived(__emptyProp ?? snippets?.empty);
let open$local = $state(false);
let activeIndex = $state(-1);
let query = $state('');
let rows: any[] = $state([]);
let windowVer = $state(0);
let editVer = $state(0);
let controlEl = $state<HTMLElement | undefined>(undefined);
let triggerEl = $state<HTMLButtonElement | undefined>(undefined);
let listEl = $state<HTMLElement | undefined>(undefined);
let __rozieRoot = $state<HTMLElement | undefined>(undefined);
// Type-ahead buffer for the select-only listbox trigger. Module-scope
// `let`s reassigned from handlers → the React emitter hoists them to `useRef`
// so they persist across renders (the setup-once guarantee); no-op elsewhere.
// They STAY in this host (not the shared spine) per the A==B rule: reassigned
// module-`let`s + sigils live in the host; the partial only closes over them.
let typeBuffer = '';
let typeTimer: any = null;
// ---- shared list spine (P2: @rozie-ui/headless-core/listCore.rzts) ------
// The option resolvers, client filter, enabled-index navigation, the keyboard
// reducer, type-ahead, single+multi selection, open/close state, and
// activeDescendant derivation now live in the shared, focus-/input-mode
// parameterized list spine. It is a compile-time `.rzts` script-partial: it
// dissolves into this leaf via inlineScriptPartials() before IR lowering (zero
// runtime dep). Listbox consumes it in focus-model `activedescendant` +
// input-mode `select-only` + multi + type-ahead. The spine closes over this
// host's pieces by convention: the reassigned module-`let`s typeBuffer/typeTimer
// (above) and the impure ref fns focusControl/scrollActiveIntoView (below).
// ══ Shared headless LIST SPINE (Phase 64, D-06) — the target-agnostic list-core bridge ══
// Lifted verbatim from Listbox.rozie's <script> (the monolithic pure-Rozie list logic). This
// partial holds ONLY the PURE list spine — option resolvers, the client-side filter, enabled-index
// navigation, the arrow/home/end/enter/escape/space/tab keyboard reducer, type-ahead, single+multi
// selection, open/close state, and activeDescendant derivation. It is a compile-time `.rzts`
// script-partial: it dissolves into each consumer's compiled leaf via inlineScriptPartials() before
// IR lowering — leaving zero runtime dependency (the 64-01-proven cross-package bare-specifier path).
//
// ── PARAMETERIZATION (D-06) ──────────────────────────────────────────────────────────────────
// The spine is parameterized BY HOST CONVENTION (the same implicit by-convention mixin contract
// windowing.rzts uses) along two axes:
// - focus-model: `activedescendant` | `roving`. Both list families default to `activedescendant`
// (what they use today): the highlighted option is tracked virtually via `activeDescendant`
// (an option id) while DOM focus stays on the control. `roving` (real per-option tabindex
// focus) is SUPPORTED-BUT-UNUSED — no focus rewrite is forced here; a roving host would supply
// its own focus mover. The `activeDescendant` / `optionId` derivation below IS the
// activedescendant model.
// - input-mode: `select-only` (Listbox — a button trigger + type-ahead) | `filter-input`
// (Combobox — a text <input> that filters by the typed query). The mode is by HOST CONVENTION,
// NOT a discriminant prop (P3 retired the Listbox `combobox`/`filterable` props): a select-only
// host never writes `$data.query`, so `visibleOptions` is the identity path for it and the
// printable-char branch of the reducer feeds type-ahead; a filter-input host writes `$data.query`
// from its <input>, so `visibleOptions` substring-filters and `onInput` drives the query.
//
// ── HOST CONTRACT (symbols the consuming host MUST define before importing) ────────────────────
// - the reassigned module-`let`s `typeBuffer` / `typeTimer` — type-ahead scratch state. They are
// reassigned from handlers → the React emitter hoists them to `useRef` (the setup-once
// guarantee), so per the A==B playbook rule they STAY IN THE HOST; this partial only closes
// over them (in `onTypeahead`).
// - `focusControl()` / `scrollActiveIntoView()` — impure ref-reading functions (they touch the
// control / list ref elements, which are post-mount-only per ROZ123), so they are per-consumer
// HOST functions; this partial only closes over them (it reads NO refs itself).
// - the option set + form surface (`$props.options` / `$props.value` (model) / `$props.multiple` /
// `$props.id` / `$props.optionLabel` / `$props.optionValue` / `$props.optionDisabled` /
// `$props.closeOnSelect` / `$props.disabled`) and the reactive state (`$data.open` /
// `$data.activeIndex` / `$data.query`). Input-mode is by convention (the host's <input> writing
// `$data.query`), NOT a discriminant prop.
// ---- option resolvers --------------------------------------------------
// ══ Shared headless LIST SPINE (Phase 64, D-06) — the target-agnostic list-core bridge ══
// Lifted verbatim from Listbox.rozie's <script> (the monolithic pure-Rozie list logic). This
// partial holds ONLY the PURE list spine — option resolvers, the client-side filter, enabled-index
// navigation, the arrow/home/end/enter/escape/space/tab keyboard reducer, type-ahead, single+multi
// selection, open/close state, and activeDescendant derivation. It is a compile-time `.rzts`
// script-partial: it dissolves into each consumer's compiled leaf via inlineScriptPartials() before
// IR lowering — leaving zero runtime dependency (the 64-01-proven cross-package bare-specifier path).
//
// ── PARAMETERIZATION (D-06) ──────────────────────────────────────────────────────────────────
// The spine is parameterized BY HOST CONVENTION (the same implicit by-convention mixin contract
// windowing.rzts uses) along two axes:
// - focus-model: `activedescendant` | `roving`. Both list families default to `activedescendant`
// (what they use today): the highlighted option is tracked virtually via `activeDescendant`
// (an option id) while DOM focus stays on the control. `roving` (real per-option tabindex
// focus) is SUPPORTED-BUT-UNUSED — no focus rewrite is forced here; a roving host would supply
// its own focus mover. The `activeDescendant` / `optionId` derivation below IS the
// activedescendant model.
// - input-mode: `select-only` (Listbox — a button trigger + type-ahead) | `filter-input`
// (Combobox — a text <input> that filters by the typed query). The mode is by HOST CONVENTION,
// NOT a discriminant prop (P3 retired the Listbox `combobox`/`filterable` props): a select-only
// host never writes `$data.query`, so `visibleOptions` is the identity path for it and the
// printable-char branch of the reducer feeds type-ahead; a filter-input host writes `$data.query`
// from its <input>, so `visibleOptions` substring-filters and `onInput` drives the query.
//
// ── HOST CONTRACT (symbols the consuming host MUST define before importing) ────────────────────
// - the reassigned module-`let`s `typeBuffer` / `typeTimer` — type-ahead scratch state. They are
// reassigned from handlers → the React emitter hoists them to `useRef` (the setup-once
// guarantee), so per the A==B playbook rule they STAY IN THE HOST; this partial only closes
// over them (in `onTypeahead`).
// - `focusControl()` / `scrollActiveIntoView()` — impure ref-reading functions (they touch the
// control / list ref elements, which are post-mount-only per ROZ123), so they are per-consumer
// HOST functions; this partial only closes over them (it reads NO refs itself).
// - the option set + form surface (`$props.options` / `$props.value` (model) / `$props.multiple` /
// `$props.id` / `$props.optionLabel` / `$props.optionValue` / `$props.optionDisabled` /
// `$props.closeOnSelect` / `$props.disabled`) and the reactive state (`$data.open` /
// `$data.activeIndex` / `$data.query`). Input-mode is by convention (the host's <input> writing
// `$data.query`), NOT a discriminant prop.
// ---- option resolvers --------------------------------------------------
const labelOf = (opt: any) => {
if (optionLabel !== null) return optionLabel(opt);
if (opt !== null && typeof opt === 'object' && 'label' in opt) return opt.label;
return String(opt);
};
const valueOf = (opt: any) => {
if (optionValue !== null) return optionValue(opt);
if (opt !== null && typeof opt === 'object' && 'value' in opt) return opt.value;
return opt;
};
const disabledOf = (opt: any) => {
if (optionDisabled !== null) return !!optionDisabled(opt);
if (opt !== null && typeof opt === 'object' && 'disabled' in opt) return !!opt.disabled;
return false;
};
const optionId = (index: any) => id + '-opt-' + index;
// ---- derived state -----------------------------------------------------
// The visible option list: identity in select-only / non-filtering mode,
// a case-insensitive substring filter when a combobox query is present.
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — a `$computed` is a value on React but an accessor on Solid, so
// aliasing it to a local (`const opts = visibleOptions()`) diverges; calling a
// plain function is identical everywhere.
// ---- derived state -----------------------------------------------------
// The visible option list: identity in select-only / non-filtering mode,
// a case-insensitive substring filter when a combobox query is present.
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — a `$computed` is a value on React but an accessor on Solid, so
// aliasing it to a local (`const opts = visibleOptions()`) diverges; calling a
// plain function is identical everywhere.
const visibleOptions = () => {
const q = (query || '').trim().toLowerCase();
if (q === '') return options;
return options.filter((opt: any) => labelOf(opt).toLowerCase().includes(q));
};
// The label shown in the (select-only) trigger when closed. A real `$computed`
// — read bare in the template, never aliased in script, so the per-target
// accessor form stays uniform.
// Is a given option currently selected? Multi compares array membership.
// Is a given option currently selected? Multi compares array membership.
const isSelected = (opt: any) => {
const v = valueOf(opt);
const cur = value;
if (multiple) return Array.isArray(cur) && cur.includes(v);
return cur === v;
};
// First enabled visible index, preferring the currently-selected option.
// First enabled visible index, preferring the currently-selected option.
const resolveInitialActive = () => {
const opts = visibleOptions();
const sel = opts.findIndex((o: any) => isSelected(o) && !disabledOf(o));
if (sel !== -1) return sel;
return opts.findIndex((o: any) => !disabledOf(o));
};
// ---- open / close ------------------------------------------------------
// Single open-state mutator → the ONLY `$emit('open-change')` site, so the
// React prop-destructure for `onOpenChange` hoists exactly once.
// ---- open / close ------------------------------------------------------
// Single open-state mutator → the ONLY `$emit('open-change')` site, so the
// React prop-destructure for `onOpenChange` hoists exactly once.
const applyExpanded = (next: any) => {
if (next && disabled) return;
if (open$local === next) return;
open$local = next;
activeIndex = next ? resolveInitialActive() : -1;
onopenchange?.({
open: next
});
};
export const open = () => applyExpanded(true);
export const close = () => applyExpanded(false);
export const toggle = () => applyExpanded(!open$local);
// ---- selection ---------------------------------------------------------
// Single `$emit('change')` site (called from both select + clear).
// ---- selection ---------------------------------------------------------
// Single `$emit('change')` site (called from both select + clear).
const fireChange = (value: any, option: any) => onchange?.({
value,
option
});
const select = (opt: any) => {
if (disabledOf(opt)) return;
const v = valueOf(opt);
if (multiple) {
const cur = value;
const arr = Array.isArray(cur) ? cur : [];
// Fresh array on every commit — in-place mutation is dropped by the
// React/Solid/Lit/Angular change detectors.
const next = arr.includes(v) ? arr.filter((x: any) => x !== v) : [...arr, v];
value = next;
fireChange(next, opt);
} else {
value = v;
fireChange(v, opt);
if (closeOnSelect) {
close();
focusControl();
}
}
};
export const clear = () => {
const empty = multiple ? [] : null;
value = empty;
query = '';
fireChange(empty, null);
};
// ---- keyboard navigation over the VISIBLE list -------------------------
// ---- keyboard navigation over the VISIBLE list -------------------------
const nextEnabled = (from: any, dir: any) => {
const opts = visibleOptions();
if (opts.length === 0) return -1;
let i = from;
for (let step = 0; step < opts.length; step++) {
i += dir;
if (i < 0) i = opts.length - 1;else if (i >= opts.length) i = 0;
if (!disabledOf(opts[i])) return i;
}
return from;
};
const move = (dir: any) => {
if (!open$local) {
open();
return;
}
const start = activeIndex < 0 ? dir > 0 ? -1 : 0 : activeIndex;
activeIndex = nextEnabled(start, dir);
scrollActiveIntoView();
};
const moveEdge = (toEnd: any) => {
if (!open$local) open();
activeIndex = toEnd ? nextEnabled(-1, -1) : nextEnabled(-1, 1);
scrollActiveIntoView();
};
const commitActive = () => {
const opts = visibleOptions();
if (activeIndex >= 0 && activeIndex < opts.length) select(opts[activeIndex]);
};
// Type-ahead for select-only listboxes: accumulate keystrokes and jump to the
// first option whose label starts with the buffer.
// Type-ahead for select-only listboxes: accumulate keystrokes and jump to the
// first option whose label starts with the buffer.
const onTypeahead = (ch: any) => {
if (typeTimer !== null) clearTimeout(typeTimer);
typeBuffer += ch.toLowerCase();
typeTimer = setTimeout(() => {
typeBuffer = '';
}, 600);
const opts = visibleOptions();
const idx = opts.findIndex((o: any) => !disabledOf(o) && labelOf(o).toLowerCase().startsWith(typeBuffer));
if (idx !== -1) {
if (!open$local) open();
activeIndex = idx;
scrollActiveIntoView();
}
};
// Key handler shared by the trigger and the combobox input. The printable-
// character branch is reached only in select-only mode (the combobox input
// types through @input).
// Key handler shared by the trigger and the combobox input. The printable-
// character branch is reached only in select-only mode (the combobox input
// types through @input).
const onControlKeyDown = ($event: any) => {
const key = $event.key;
if (key === 'ArrowDown') {
$event.preventDefault();
move(1);
} else if (key === 'ArrowUp') {
$event.preventDefault();
move(-1);
} else if (key === 'Home') {
$event.preventDefault();
moveEdge(false);
} else if (key === 'End') {
$event.preventDefault();
moveEdge(true);
} else if (key === 'Enter') {
if (open$local) {
$event.preventDefault();
commitActive();
}
} else if (key === 'Escape') {
if (open$local) {
$event.preventDefault();
close();
focusControl();
}
} else if (key === ' ' || key === 'Spacebar') {
// Space toggles / commits in a select-only host (a button trigger). A
// filter-input host types the literal space into its <input> and does NOT
// route Space through this reducer, so this branch is select-only by use.
$event.preventDefault();
if (!open$local) open();else commitActive();
} else if (key === 'Tab') {
if (open$local) close();
} else if (key.length === 1 && !$event.metaKey && !$event.ctrlKey && !$event.altKey) {
onTypeahead(key);
}
};
// Combobox input handler: keep the popup open while typing, reset the active
// highlight to the first match, and surface the query for remote filtering.
// Pointer hover sets the virtual highlight (matches native <select> feel).
// Pointer hover sets the virtual highlight (matches native <select> feel).
const onOptionPointerMove = (index: any) => {
if (activeIndex !== index) activeIndex = index;
};
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
const virtualItemKey = (i: any) => {
const src = windowSource();
return src && src[i] ? src[i].id : undefined;
};
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
const virtualizerOptions = (): any => ({
count: windowSource().length,
getScrollElement: () => gridScrollEl,
estimateSize: () => estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
windowVer = windowVer + 1;
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
scheduleRemeasure();
}
});
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
const pinMeasurement = (pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null => pinnedMeasurement(pin);
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
const windowedRows = () => {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void windowVer;
void editVer;
if (!virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!virtual) {
const rowList = rows || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = virtualizer.getVirtualItems();
const rowList = rows || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
};
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
const padTop = () => {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void windowVer;
void editVer;
if (!virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
};
const padBottom = () => {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void windowVer;
void editVer;
if (!virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
};
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
const pmIndexInWindow = (items: any, idx: any) => {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
};
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
const rowIsOutsideWindow = (r: any) => {
if (!virtual || !virtualizer) return false;
const items = virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally
// (a peer dep); every RUNTIME reference sits behind `if ($props.virtual)` / a
// `virtualizer` guard so the non-virtual emitted path executes none of it
// (byte-identical-off).
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally
// (a peer dep); every RUNTIME reference sits behind `if ($props.virtual)` / a
// `virtualizer` guard so the non-virtual emitted path executes none of it
// (byte-identical-off).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
let remeasurePending = false;
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// set. CR-02: the shared windowing contract requires each row to carry a STABLE `.id`
// (windowing.rzts virtualItemKey reads src[i].id, and the windowed template keys on
// wr.row.id). A raw Listbox option is a primitive or a bare { label, value, disabled }
// — NOT guaranteed to have `.id` — so an unwrapped raw set keyed on wr.row.id collapses
// every framework :key (and every virtual-core measurement key) to `undefined`, which
// recycles the wrong DOM node as the window scrolls. Wrap each option into an id-bearing
// row the way the sibling Combobox's filteredOptions() does — `id` is the resolved
// value, `_opt` the original option (read via wr.row._opt in the windowed template),
// `_i` the source index. Kept === $data.rows so the math's rowList[vi.index] resolves to
// the same wrapped row the count windows over.
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// set. CR-02: the shared windowing contract requires each row to carry a STABLE `.id`
// (windowing.rzts virtualItemKey reads src[i].id, and the windowed template keys on
// wr.row.id). A raw Listbox option is a primitive or a bare { label, value, disabled }
// — NOT guaranteed to have `.id` — so an unwrapped raw set keyed on wr.row.id collapses
// every framework :key (and every virtual-core measurement key) to `undefined`, which
// recycles the wrong DOM node as the window scrolls. Wrap each option into an id-bearing
// row the way the sibling Combobox's filteredOptions() does — `id` is the resolved
// value, `_opt` the original option (read via wr.row._opt in the windowed template),
// `_i` the source index. Kept === $data.rows so the math's rowList[vi.index] resolves to
// the same wrapped row the count windows over.
const windowSource = () => visibleOptions().map((o: any, i: any) => ({
id: valueOf(o),
_opt: o,
_i: i
}));
// D-05 NO-OP PIN HOOK (defined in THIS host, NOT the shared partial — keeps data-table
// A==B intact). The shared windowedRows/padTop/padBottom call pinnedEditIndex()/
// pinnedMeasurement() UNGUARDED by convention; a listbox has no edit-pinning, so these
// reduce the pin union (-1 → never unioned) and the spacer subtraction (null → identity)
// to a no-op. They MUST exist or the by-convention call ReferenceErrors at mount.
// D-05 NO-OP PIN HOOK (defined in THIS host, NOT the shared partial — keeps data-table
// A==B intact). The shared windowedRows/padTop/padBottom call pinnedEditIndex()/
// pinnedMeasurement() UNGUARDED by convention; a listbox has no edit-pinning, so these
// reduce the pin union (-1 → never unioned) and the spacer subtraction (null → identity)
// to a no-op. They MUST exist or the by-convention call ReferenceErrors at mount.
const pinnedEditIndex = () => -1;
const pinnedMeasurement = (pin: any) => null;
// Keep $data.rows === windowSource() so the windowing math indexes the live option set.
// Keep $data.rows === windowSource() so the windowing math indexes the live option set.
const syncRows = () => {
rows = windowSource();
};
// Defer remeasureWindow() until AFTER the framework commits the recycled window
// (onChange fires BEFORE React/Solid commit). TWO deferred passes (microtask THEN rAF)
// behind one in-flight flag (the data-table virtualization.rzts:46-56 pattern, copied
// per-consumer per D-04/D-09): the microtask catches Solid's <For> / Svelte's {#each}
// SYNCHRONOUS commit (the Phase 63 Solid under-convergence hazard — D-09 rAF-defer
// budget), the rAF catches React's async commit. measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
// Defer remeasureWindow() until AFTER the framework commits the recycled window
// (onChange fires BEFORE React/Solid commit). TWO deferred passes (microtask THEN rAF)
// behind one in-flight flag (the data-table virtualization.rzts:46-56 pattern, copied
// per-consumer per D-04/D-09): the microtask catches Solid's <For> / Svelte's {#each}
// SYNCHRONOUS commit (the Phase 63 Solid under-convergence hazard — D-09 rAF-defer
// budget), the rAF catches React's async commit. measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
const scheduleRemeasure = () => {
if (remeasurePending) return;
remeasurePending = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
const rafPass = () => {
remeasurePending = false;
remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
};
// measureElement sweep: hand every rendered windowed option to the virtualizer so its
// true (variable) height is observed (virtual-core measures ONLY nodes passed to
// measureElement, keyed by the data-index attribute). Bails during a programmatic
// scroll (scrollToIndex) so a measure can't starve the scroll target.
// measureElement sweep: hand every rendered windowed option to the virtualizer so its
// true (variable) height is observed (virtual-core measures ONLY nodes passed to
// measureElement, keyed by the data-index attribute). Bails during a programmatic
// scroll (scrollToIndex) so a measure can't starve the scroll target.
const remeasureWindow = () => {
if (!virtualizer || !gridScrollEl) return;
if (virtualizer.scrollState) return;
const els = gridScrollEl.querySelectorAll('.rozie-listbox-option[data-index]');
for (const el of els as any) virtualizer.measureElement(el);
};
// ---- focus / scroll helpers (post-mount $refs only) --------------------
// Impure ($refs) → per the ROZ123 + A==B rules they stay in the host (the spine
// only closes over them). Named `focusControl` (not `focus`): a `focus` $expose
// verb would override the inherited HTMLElement.focus method on the Lit element.
// ---- focus / scroll helpers (post-mount $refs only) --------------------
// Impure ($refs) → per the ROZ123 + A==B rules they stay in the host (the spine
// only closes over them). Named `focusControl` (not `focus`): a `focus` $expose
// verb would override the inherited HTMLElement.focus method on the Lit element.
export const focusControl = () => {
triggerEl?.focus();
};
// Keep the active option visible inside the scrolling listbox. Reads $refs in
// a post-mount callback only (never eagerly — ROZ123). When windowing, route through
// the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window is
// scrolled into view (the windowed-arrow-nav seam); else the native scrollIntoView.
// Keep the active option visible inside the scrolling listbox. Reads $refs in
// a post-mount callback only (never eagerly — ROZ123). When windowing, route through
// the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window is
// scrolled into view (the windowed-arrow-nav seam); else the native scrollIntoView.
const scrollActiveIntoView = () => {
if (activeIndex < 0) return;
if (virtual && virtualizer) {
// 'center' (not 'auto'): keep the active option well inside the rendered slice as the
// window scrolls — 'auto' lands it at the viewport edge where the overscan band can
// leave it just-unrendered for a frame on the fine-grained targets (Solid).
virtualizer.scrollToIndex(activeIndex, {
align: 'center'
});
scheduleRemeasure();
return;
}
if (!listEl) return;
const el = listEl!.querySelector('#' + CSS.escape(optionId(activeIndex)));
el?.scrollIntoView({
block: 'nearest'
});
};
// ---- windowing lifecycle (post-mount; ONLY when virtual) ----------------
// kickWindow: the cross-target first-paint settle. Re-captures the LIVE scroll element,
// re-feeds the CURRENT option count into the virtualizer, re-attaches its rect observer
// (_willUpdate), and bumps the windowVer signal so the windowed <For>/{#each}/repeat
// re-derives. Retried over a few frames because (a) virtual-core measures the scroll rect
// asynchronously (D-09 Solid rAF-defer — a synchronous kick sees rectH 0 → empty window),
// (b) Solid/Lit recreate the list node between mount and first commit (leaving virtual-core's
// scrollElement stale), and (c) the consumer often seeds options AFTER the listbox mounts
// (Lit/React), so the count must be re-read once the prop propagates. Stops once the window
// paints (or attempts run out) — idempotent + loop-free.
// ---- windowing lifecycle (post-mount; ONLY when virtual) ----------------
// kickWindow: the cross-target first-paint settle. Re-captures the LIVE scroll element,
// re-feeds the CURRENT option count into the virtualizer, re-attaches its rect observer
// (_willUpdate), and bumps the windowVer signal so the windowed <For>/{#each}/repeat
// re-derives. Retried over a few frames because (a) virtual-core measures the scroll rect
// asynchronously (D-09 Solid rAF-defer — a synchronous kick sees rectH 0 → empty window),
// (b) Solid/Lit recreate the list node between mount and first commit (leaving virtual-core's
// scrollElement stale), and (c) the consumer often seeds options AFTER the listbox mounts
// (Lit/React), so the count must be re-read once the prop propagates. Stops once the window
// paints (or attempts run out) — idempotent + loop-free.
const kickWindow = (attempts: any) => {
if (!virtualizer) return;
gridScrollEl = __rozieRoot ? __rozieRoot!.querySelector('.rozie-listbox-list') : gridScrollEl;
// Only re-feed the count from a NON-EMPTY source: on React these rAF closures capture
// stale (mount-time, empty) props, so feeding here would CLOBBER the $watch's correct
// count back to 0. The $watch (fresh useEffect props) owns React's count; the kick owns
// the Solid/Lit scroll-element re-attach + the deferred windowVer re-derive.
if (windowSource().length > 0) {
syncRows();
virtualizer.setOptions(virtualizerOptions());
}
virtualizer._willUpdate();
windowVer = windowVer + 1;
remeasureWindow();
if (windowedRows().length === 0 && attempts > 0) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(attempts - 1));else setTimeout(() => kickWindow(attempts - 1), 16);
}
};
const selectedLabel = $derived.by(() => {
const cur = value;
if (multiple) {
// Read the model value into a local before narrowing: `$props.value` lowers
// to a `value()` accessor on Solid, and Array.isArray() can't narrow two
// separate calls — narrowing one stable local works on every target.
const arr = Array.isArray(cur) ? cur : [];
if (arr.length === 0) return '';
return options.filter((o: any) => arr.includes(valueOf(o))).map(labelOf).join(', ');
}
const match = options.find((o: any) => valueOf(o) === cur);
return match === undefined ? '' : labelOf(match);
});
const activeDescendant = $derived.by(() => {
if (!open$local || activeIndex < 0) return null;
return optionId(activeIndex);
});
onMount(() => {
syncRows();
if (virtual) {
// The list renders at mount when virtual, so the .rozie-listbox-list scroll container
// exists here. Capture it via $el.querySelector (the data-table gridScrollEl precedent,
// proven ×6 incl Lit shadow + Solid) — $refs on a conditionally-rendered node is null on
// Solid/Lit, which leaves the virtualizer with no scroll element.
gridScrollEl = __rozieRoot ? __rozieRoot!.querySelector('.rozie-listbox-list') : null;
virtualizer = new Virtualizer(virtualizerOptions());
virtualizerCleanup = virtualizer._didMount();
windowVer = windowVer + 1;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(8));else setTimeout(() => kickWindow(8), 0);
}
});
onDestroy(() => (() => {
if (typeTimer !== null) clearTimeout(typeTimer);
// Tear down the virtualizer's scroll-element ResizeObserver (no-op when virtual off).
if (virtualizerCleanup) virtualizerCleanup();
})());
let __rozieWatchInitial_0 = true;
$effect(() => { (() => (options ? options.length : 0) + '|' + query)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } (() => {
syncRows();
if (virtual && virtualizer) {
gridScrollEl = __rozieRoot ? __rozieRoot!.querySelector('.rozie-listbox-list') : gridScrollEl;
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
windowVer = windowVer + 1;
scheduleRemeasure();
}
})(); }); });
$effect(() => {
if (!(open$local)) return;
const handler = ($event: MouseEvent) => {
const target = $event.target as Node;
if (controlEl?.contains(target) || listEl?.contains(target)) return;
close();
};
let attached = false;
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled) return;
document.addEventListener('click', handler);
attached = true;
}, 0);
return () => {
cancelled = true;
clearTimeout(timer);
if (attached) document.removeEventListener('click', handler);
};
});
</script>
<div bind:this={__rozieRoot} {...__rozieAttrs} class={["rozie-listbox", { 'rozie-listbox-open': open$local, 'rozie-listbox-disabled': disabled, 'rozie-listbox-inline': inline }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-b576227a><div class="rozie-listbox-control" bind:this={controlEl} data-rozie-s-b576227a><button bind:this={triggerEl} type="button" class="rozie-listbox-trigger" role="combobox" aria-haspopup="listbox" aria-expanded={open$local} aria-controls={rozieAttr(id + '-list')} aria-activedescendant={rozieAttr(activeDescendant)} aria-label={ariaLabel} disabled={disabled} onclick={toggle} onkeydown={($event) => { onControlKeyDown($event); }} data-rozie-s-b576227a>{#if selected}{@render selected({ selected: selectedLabel, value })}{:else}{#if selectedLabel}<span class="rozie-listbox-selected" data-rozie-s-b576227a>{rozieDisplay(selectedLabel)}</span>{:else}<span class="rozie-listbox-placeholder" data-rozie-s-b576227a>{placeholder}</span>{/if}{/if}<span class="rozie-listbox-arrow" aria-hidden="true" data-rozie-s-b576227a>▾</span></button></div>{#if open$local && !virtual}<div bind:this={listEl} class="rozie-listbox-list" role="listbox" id={rozieAttr(id + '-list')} aria-label={ariaLabel} aria-multiselectable={multiple} data-rozie-s-b576227a>{#each visibleOptions() as opt, index (optionId(index))}<div id={rozieAttr(optionId(index))} class={["rozie-listbox-option", { 'is-active': activeIndex === index, 'is-selected': isSelected(opt), 'is-disabled': disabledOf(opt) }]} role="option" aria-selected={!!isSelected(opt)} aria-disabled={!!disabledOf(opt)} onclick={($event) => { select(opt); }} onmousemove={($event) => { onOptionPointerMove(index); }} data-rozie-s-b576227a>{#if option}{@render option({ option: opt, index, active: activeIndex === index, selected: isSelected(opt), disabled: disabledOf(opt) })}{:else}{rozieDisplay(labelOf(opt))}{/if}</div>{/each}{#if visibleOptions().length === 0}<div class="rozie-listbox-empty" role="presentation" data-rozie-s-b576227a>{#if empty}{@render empty({ query })}{:else}No options{/if}</div>{/if}</div>{/if}{#if virtual}<div bind:this={listEl} class="rozie-listbox-list rozie-listbox-list--virtual" role="listbox" id={rozieAttr(id + '-list')} aria-label={ariaLabel} aria-multiselectable={multiple} style={rozieStyle((open$local ? '' : 'display:none;') + (maxHeight ? 'height:' + maxHeight + ';max-height:' + maxHeight + ';overflow-y:auto;--rozie-listbox-max-height:' + maxHeight : 'overflow-y:auto'))} data-rozie-s-b576227a><div class="rozie-listbox-spacer" aria-hidden="true" style={rozieStyle('height:' + padTop() + 'px')} data-rozie-s-b576227a></div>{#each windowedRows() as wr (wr.row.id)}<div id={rozieAttr(optionId(wr.vi.index))} data-index={rozieAttr(wr.vi.index)} class={["rozie-listbox-option", { 'is-active': activeIndex === wr.vi.index, 'is-selected': isSelected(wr.row._opt), 'is-disabled': disabledOf(wr.row._opt) }]} role="option" aria-selected={!!isSelected(wr.row._opt)} aria-disabled={!!disabledOf(wr.row._opt)} onclick={($event) => { select(wr.row._opt); }} onmousemove={($event) => { onOptionPointerMove(wr.vi.index); }} data-rozie-s-b576227a>{#if option}{@render option({ option: wr.row._opt, index: wr.vi.index, active: activeIndex === wr.vi.index, selected: isSelected(wr.row._opt), disabled: disabledOf(wr.row._opt) })}{:else}{rozieDisplay(labelOf(wr.row._opt))}{/if}</div>{/each}<div class="rozie-listbox-spacer" aria-hidden="true" style={rozieStyle('height:' + padBottom() + 'px')} data-rozie-s-b576227a></div>{#if windowSource().length === 0}<div class="rozie-listbox-empty" role="presentation" data-rozie-s-b576227a>{#if empty}{@render empty({ query })}{:else}No options{/if}</div>{/if}</div>{/if}</div>
<style>
:global {
.rozie-listbox[data-rozie-s-b576227a] {
position: relative;
display: inline-block;
min-width: var(--rozie-listbox-min-width, 12rem);
font: var(--rozie-listbox-font, inherit);
}
.rozie-listbox-control[data-rozie-s-b576227a] { display: block; }
.rozie-listbox-input[data-rozie-s-b576227a],
.rozie-listbox-trigger[data-rozie-s-b576227a] {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
padding: var(--rozie-listbox-control-padding, 0.5rem 0.75rem);
font: inherit;
text-align: left;
background: var(--rozie-listbox-bg, #fff);
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-border, rgba(0, 0, 0, 0.2));
border-radius: var(--rozie-listbox-radius, 6px);
cursor: pointer;
}
.rozie-listbox-input[data-rozie-s-b576227a] { cursor: text; }
.rozie-listbox-input[data-rozie-s-b576227a]:focus-visible,
.rozie-listbox-input[data-rozie-s-b576227a]:focus,
.rozie-listbox-trigger[data-rozie-s-b576227a]:focus-visible,
.rozie-listbox-trigger[data-rozie-s-b576227a]:focus {
outline: var(--rozie-listbox-ring-width, 2px) solid var(--rozie-listbox-ring, var(--rozie-listbox-accent, #0066cc));
outline-offset: var(--rozie-listbox-ring-offset, 1px);
}
.rozie-listbox-disabled[data-rozie-s-b576227a] { opacity: var(--rozie-listbox-disabled-opacity, 0.6); pointer-events: none; }
.rozie-listbox-placeholder[data-rozie-s-b576227a] { color: var(--rozie-listbox-placeholder, rgba(0, 0, 0, 0.45)); }
.rozie-listbox-arrow[data-rozie-s-b576227a] {
font-size: 0.75em;
color: var(--rozie-listbox-arrow-color, currentColor);
opacity: var(--rozie-listbox-arrow-opacity, 0.7);
}
.rozie-listbox-list[data-rozie-s-b576227a] {
position: absolute;
z-index: var(--rozie-listbox-z, 1000);
top: calc(100% + var(--rozie-listbox-popup-offset, 4px));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-listbox-popup-padding, 0.25rem);
max-height: var(--rozie-listbox-max-height, 16rem);
overflow-y: auto;
list-style: none;
background: var(--rozie-listbox-popup-bg, var(--rozie-listbox-bg, #fff));
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-popup-border, var(--rozie-listbox-border, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-listbox-popup-radius, var(--rozie-listbox-radius, 6px));
box-shadow: var(--rozie-listbox-shadow, 0 6px 24px rgba(0, 0, 0, 0.12));
}
.rozie-listbox-inline[data-rozie-s-b576227a] {
display: block;
width: 100%;
}
.rozie-listbox-inline[data-rozie-s-b576227a] .rozie-listbox-list[data-rozie-s-b576227a] {
position: static;
margin-top: var(--rozie-listbox-popup-offset, 4px);
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-listbox-option[data-rozie-s-b576227a] {
padding: var(--rozie-listbox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-listbox-option-radius, 4px);
color: var(--rozie-listbox-option-fg, inherit);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
}
.rozie-listbox-option.is-active[data-rozie-s-b576227a] {
background: var(--rozie-listbox-active-bg, rgba(0, 102, 204, 0.12));
color: var(--rozie-listbox-active-fg, inherit);
}
.rozie-listbox-option.is-selected[data-rozie-s-b576227a] {
background: var(--rozie-listbox-selected-bg, transparent);
color: var(--rozie-listbox-selected-fg, inherit);
font-weight: var(--rozie-listbox-selected-weight, 600);
}
.rozie-listbox-option.is-selected[data-rozie-s-b576227a]::after {
content: var(--rozie-listbox-check, '✓');
color: var(--rozie-listbox-check-color, var(--rozie-listbox-accent, #0066cc));
}
.rozie-listbox-option.is-disabled[data-rozie-s-b576227a] { opacity: var(--rozie-listbox-disabled-opacity, 0.45); cursor: not-allowed; }
.rozie-listbox-empty[data-rozie-s-b576227a] { padding: var(--rozie-listbox-option-padding, 0.5rem 0.6rem); color: var(--rozie-listbox-empty-fg, rgba(0, 0, 0, 0.5)); }
.rozie-listbox-spacer[data-rozie-s-b576227a] { margin: 0; padding: 0; border: 0; flex: none; }
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally
// (a peer dep); every RUNTIME reference sits behind `if ($props.virtual)` / a
// `virtualizer` guard so the non-virtual emitted path executes none of it
// (byte-identical-off).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
interface SelectedCtx {
$implicit: { selected: any; value: any };
selected: any;
value: any;
}
interface OptionCtx {
$implicit: { option: any; index: any; active: any; selected: any; disabled: any };
option: any;
index: any;
active: any;
selected: any;
disabled: any;
}
interface EmptyCtx {
$implicit: { query: any };
query: any;
}
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
@Component({
selector: 'rozie-listbox',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<div class="rozie-listbox" [ngClass]="{ 'rozie-listbox-open': open$local(), 'rozie-listbox-disabled': (disabled() || this.__rozieCvaDisabled()), 'rozie-listbox-inline': inline() }" #__rozieRoot #rozieSpread_0 #rozieListenersTarget_1>
<div class="rozie-listbox-control" #controlEl>
<button #triggerEl type="button" class="rozie-listbox-trigger" role="combobox" aria-haspopup="listbox" [attr.aria-expanded]="open$local()" [attr.aria-controls]="rozieAttr(id() + '-list')" [attr.aria-activedescendant]="rozieAttr(activeDescendant())" [attr.aria-label]="ariaLabel()" [disabled]="(disabled() || this.__rozieCvaDisabled())" (click)="toggle()" (keydown)="onControlKeyDown($event)">
@if ((selectedTpl ?? templates()?.['selected'])) {
<ng-container *ngTemplateOutlet="(selectedTpl ?? templates()?.['selected']); context: { $implicit: { selected: selectedLabel(), value: value() }, selected: selectedLabel(), value: value() }" />
} @else {
@if (selectedLabel()) {
<span class="rozie-listbox-selected">{{ rozieDisplay(selectedLabel()) }}</span>
} @else {
<span class="rozie-listbox-placeholder">{{ placeholder() }}</span>
}
}
<span class="rozie-listbox-arrow" aria-hidden="true">▾</span>
</button>
</div>
@if (open$local() && !virtual()) {
<div #listEl class="rozie-listbox-list" role="listbox" [attr.id]="rozieAttr(id() + '-list')" [attr.aria-label]="ariaLabel()" [attr.aria-multiselectable]="multiple()">
@for (opt of visibleOptions(); track optionId(index); let index = $index) {
<div [attr.id]="rozieAttr(optionId(index))" class="rozie-listbox-option" [ngClass]="{ 'is-active': activeIndex() === index, 'is-selected': isSelected(opt), 'is-disabled': disabledOf(opt) }" role="option" [attr.aria-selected]="!!isSelected(opt)" [attr.aria-disabled]="!!disabledOf(opt)" (click)="select(opt)" (mousemove)="onOptionPointerMove(index)">
@if ((optionTpl ?? templates()?.['option'])) {
<ng-container *ngTemplateOutlet="(optionTpl ?? templates()?.['option']); context: { $implicit: { option: opt, index: index, active: activeIndex() === index, selected: isSelected(opt), disabled: disabledOf(opt) }, option: opt, index: index, active: activeIndex() === index, selected: isSelected(opt), disabled: disabledOf(opt) }" />
} @else {
{{ rozieDisplay(labelOf(opt)) }}
}
</div>
}
@if (visibleOptions().length === 0) {
<div class="rozie-listbox-empty" role="presentation">
@if ((emptyTpl ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? templates()?.['empty']); context: { $implicit: { query: query() }, query: query() }" />
} @else {
No options
}
</div>
}</div>
}@if (virtual()) {
<div #listEl class="rozie-listbox-list rozie-listbox-list--virtual" role="listbox" [attr.id]="rozieAttr(id() + '-list')" [attr.aria-label]="ariaLabel()" [attr.aria-multiselectable]="multiple()" [style]="__style">
<div class="rozie-listbox-spacer" aria-hidden="true" [style]="'height:' + padTop() + 'px'"></div>
@for (wr of windowedRows(); track wr.row.id) {
<div [attr.id]="rozieAttr(optionId(wr.vi.index))" [attr.data-index]="rozieAttr(wr.vi.index)" class="rozie-listbox-option" [ngClass]="{ 'is-active': activeIndex() === wr.vi.index, 'is-selected': isSelected(wr.row._opt), 'is-disabled': disabledOf(wr.row._opt) }" role="option" [attr.aria-selected]="!!isSelected(wr.row._opt)" [attr.aria-disabled]="!!disabledOf(wr.row._opt)" (click)="select(wr.row._opt)" (mousemove)="onOptionPointerMove(wr.vi.index)">
@if ((optionTpl ?? templates()?.['option'])) {
<ng-container *ngTemplateOutlet="(optionTpl ?? templates()?.['option']); context: { $implicit: { option: wr.row._opt, index: wr.vi.index, active: activeIndex() === wr.vi.index, selected: isSelected(wr.row._opt), disabled: disabledOf(wr.row._opt) }, option: wr.row._opt, index: wr.vi.index, active: activeIndex() === wr.vi.index, selected: isSelected(wr.row._opt), disabled: disabledOf(wr.row._opt) }" />
} @else {
{{ rozieDisplay(labelOf(wr.row._opt)) }}
}
</div>
}
<div class="rozie-listbox-spacer" aria-hidden="true" [style]="'height:' + padBottom() + 'px'"></div>
@if (windowSource().length === 0) {
<div class="rozie-listbox-empty" role="presentation">
@if ((emptyTpl ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? templates()?.['empty']); context: { $implicit: { query: query() }, query: query() }" />
} @else {
No options
}
</div>
}</div>
}</div>
`,
styles: [`
.rozie-listbox {
position: relative;
display: inline-block;
min-width: var(--rozie-listbox-min-width, 12rem);
font: var(--rozie-listbox-font, inherit);
}
.rozie-listbox-control { display: block; }
.rozie-listbox-input,
.rozie-listbox-trigger {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
padding: var(--rozie-listbox-control-padding, 0.5rem 0.75rem);
font: inherit;
text-align: left;
background: var(--rozie-listbox-bg, #fff);
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-border, rgba(0, 0, 0, 0.2));
border-radius: var(--rozie-listbox-radius, 6px);
cursor: pointer;
}
.rozie-listbox-input { cursor: text; }
.rozie-listbox-input:focus-visible,
.rozie-listbox-input:focus,
.rozie-listbox-trigger:focus-visible,
.rozie-listbox-trigger:focus {
outline: var(--rozie-listbox-ring-width, 2px) solid var(--rozie-listbox-ring, var(--rozie-listbox-accent, #0066cc));
outline-offset: var(--rozie-listbox-ring-offset, 1px);
}
.rozie-listbox-disabled { opacity: var(--rozie-listbox-disabled-opacity, 0.6); pointer-events: none; }
.rozie-listbox-placeholder { color: var(--rozie-listbox-placeholder, rgba(0, 0, 0, 0.45)); }
.rozie-listbox-arrow {
font-size: 0.75em;
color: var(--rozie-listbox-arrow-color, currentColor);
opacity: var(--rozie-listbox-arrow-opacity, 0.7);
}
.rozie-listbox-list {
position: absolute;
z-index: var(--rozie-listbox-z, 1000);
top: calc(100% + var(--rozie-listbox-popup-offset, 4px));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-listbox-popup-padding, 0.25rem);
max-height: var(--rozie-listbox-max-height, 16rem);
overflow-y: auto;
list-style: none;
background: var(--rozie-listbox-popup-bg, var(--rozie-listbox-bg, #fff));
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-popup-border, var(--rozie-listbox-border, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-listbox-popup-radius, var(--rozie-listbox-radius, 6px));
box-shadow: var(--rozie-listbox-shadow, 0 6px 24px rgba(0, 0, 0, 0.12));
}
.rozie-listbox-inline {
display: block;
width: 100%;
}
.rozie-listbox-inline .rozie-listbox-list {
position: static;
margin-top: var(--rozie-listbox-popup-offset, 4px);
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-listbox-option {
padding: var(--rozie-listbox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-listbox-option-radius, 4px);
color: var(--rozie-listbox-option-fg, inherit);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
}
.rozie-listbox-option.is-active {
background: var(--rozie-listbox-active-bg, rgba(0, 102, 204, 0.12));
color: var(--rozie-listbox-active-fg, inherit);
}
.rozie-listbox-option.is-selected {
background: var(--rozie-listbox-selected-bg, transparent);
color: var(--rozie-listbox-selected-fg, inherit);
font-weight: var(--rozie-listbox-selected-weight, 600);
}
.rozie-listbox-option.is-selected::after {
content: var(--rozie-listbox-check, '✓');
color: var(--rozie-listbox-check-color, var(--rozie-listbox-accent, #0066cc));
}
.rozie-listbox-option.is-disabled { opacity: var(--rozie-listbox-disabled-opacity, 0.45); cursor: not-allowed; }
.rozie-listbox-empty { padding: var(--rozie-listbox-option-padding, 0.5rem 0.6rem); color: var(--rozie-listbox-empty-fg, rgba(0, 0, 0, 0.5)); }
.rozie-listbox-spacer { margin: 0; padding: 0; border: 0; flex: none; }
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Listbox),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Listbox {
/**
* The option set. Each entry is either a primitive (`string`/`number`) or an object; objects resolve their label, value, and disabled state via the `option*` resolver props, falling back to `.label` / `.value` / `.disabled`.
*/
options = input<any[]>((() => [])());
/**
* The selected value (two-way `r-model`) — a scalar in single-select, an array of values in multi-select. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Listbox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
* @example
* <Listbox r-model:value="fruit" :options="fruits" />
*/
value = model<(unknown) | null>(null);
/**
* Enable multi-select: `value` becomes an array, selecting an option toggles its membership, and the popup stays open after each commit.
*/
multiple = input<boolean>(false);
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the listbox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
inline = input<boolean>(false);
/**
* Disable the control entirely. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled = input<boolean>(false);
/**
* Placeholder text shown in the empty control.
*/
placeholder = input<string>('');
/**
* Close the popup after a single-select commit. Defaults `true`; multi-select keeps the popup open regardless of this setting.
*/
closeOnSelect = input<boolean>(true);
/**
* Resolver override for an object option's display label — `(option) => string`. Falls back to the option's `.label` property.
*/
optionLabel = input<((...args: unknown[]) => unknown) | null>(null);
/**
* Resolver override for an object option's committed value — `(option) => value`. Falls back to the option's `.value` property.
*/
optionValue = input<((...args: unknown[]) => unknown) | null>(null);
/**
* Resolver override marking an option non-selectable — `(option) => boolean`. Falls back to the option's `.disabled` property.
*/
optionDisabled = input<((...args: unknown[]) => unknown) | null>(null);
/**
* Stable id base for the ARIA wiring (the listbox id, per-option ids, and `aria-activedescendant`). Give each instance on a page a distinct id so these references stay unique.
*/
id = input<string>('rozie-listbox');
/**
* Accessible name for the control when there is no visible `<label for>` pointing at its `id` (`aria-label`).
*/
ariaLabel = input<(string) | null>(null);
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling list (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed listbox. Pair with `inline` + `maxHeight` so the windowed scroll container is bounded.
*/
virtual = input<boolean>(false);
/**
* Estimated option row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight = input<number>(36);
/**
* A CSS length string bounding the list scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-listbox-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight = input<string>('');
open$local = signal(false);
activeIndex = signal(-1);
query = signal('');
rows = signal<any[]>([]);
windowVer = signal(0);
editVer = signal(0);
controlEl = viewChild<ElementRef<HTMLDivElement>>('controlEl');
triggerEl = viewChild<ElementRef<HTMLButtonElement>>('triggerEl');
listEl = viewChild<ElementRef<HTMLDivElement>>('listEl');
__rozieRoot = viewChild<ElementRef<HTMLDivElement>>('__rozieRoot');
openChange = output<unknown>({ alias: 'open-change' });
change = output<unknown>();
@ContentChild('selected', { read: TemplateRef }) selectedTpl?: TemplateRef<SelectedCtx>;
@ContentChild('option', { read: TemplateRef }) optionTpl?: TemplateRef<OptionCtx>;
@ContentChild('empty', { read: TemplateRef }) emptyTpl?: TemplateRef<EmptyCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private __rozieWatchInitial_0 = true;
constructor() {
const renderer = inject(Renderer2);
effect((onCleanup) => {
if (!(this.open$local())) return;
const handler = ($event: MouseEvent) => {
const target = $event.target as Node;
if (this.controlEl()?.nativeElement?.contains(target) || this.listEl()?.nativeElement?.contains(target)) return;
this.close();
};
const unlisten = renderer.listen('document', 'click', handler);
onCleanup(unlisten);
});
inject(DestroyRef).onDestroy(() => {
if (this.typeTimer !== null) clearTimeout(this.typeTimer);
// Tear down the virtualizer's scroll-element ResizeObserver (no-op when virtual off).
if (this.virtualizerCleanup) this.virtualizerCleanup();
});
effect(() => { const __watchVal = (() => (this.options() ? this.options().length : 0) + '|' + this.query())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
this.syncRows();
if (this.virtual() && this.virtualizer) {
this.gridScrollEl = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rozie-listbox-list') : this.gridScrollEl;
this.virtualizer.setOptions(this.virtualizerOptions());
this.virtualizer._willUpdate();
this.windowVer.set(this.windowVer() + 1);
this.scheduleRemeasure();
}
})(); }); });
}
ngAfterViewInit() {
this.syncRows();
if (this.virtual()) {
// The list renders at mount when virtual, so the .rozie-listbox-list scroll container
// exists here. Capture it via $el.querySelector (the data-table gridScrollEl precedent,
// proven ×6 incl Lit shadow + Solid) — $refs on a conditionally-rendered node is null on
// Solid/Lit, which leaves the virtualizer with no scroll element.
this.gridScrollEl = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rozie-listbox-list') : null;
this.virtualizer = new Virtualizer(this.virtualizerOptions());
this.virtualizerCleanup = this.virtualizer._didMount();
this.windowVer.set(this.windowVer() + 1);
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => this.kickWindow(8));else setTimeout(() => this.kickWindow(8), 0);
}
}
selectedLabel = computed(() => {
const __options = this.options();
const cur = this.value();
if (this.multiple()) {
// Read the model value into a local before narrowing: `$props.value` lowers
// to a `value()` accessor on Solid, and Array.isArray() can't narrow two
// separate calls — narrowing one stable local works on every target.
const arr = Array.isArray(cur) ? cur : [];
if (arr.length === 0) return '';
return __options.filter((o: any) => arr.includes(this.valueOf$local(o))).map(this.labelOf).join(', ');
}
const match = __options.find((o: any) => this.valueOf$local(o) === cur);
return match === undefined ? '' : this.labelOf(match);
});
activeDescendant = computed(() => {
const __activeIndex = this.activeIndex();
if (!this.open$local() || __activeIndex < 0) return null;
return this.optionId(__activeIndex);
});
typeBuffer = '';
typeTimer: any = null;
labelOf = (opt: any) => {
const __optionLabel = this.optionLabel();
if (__optionLabel !== null) return __optionLabel(opt);
if (opt !== null && typeof opt === 'object' && 'label' in opt) return opt.label;
return String(opt);
};
valueOf$local = (opt: any) => {
const __optionValue = this.optionValue();
if (__optionValue !== null) return __optionValue(opt);
if (opt !== null && typeof opt === 'object' && 'value' in opt) return opt.value;
return opt;
};
disabledOf = (opt: any) => {
const __optionDisabled = this.optionDisabled();
if (__optionDisabled !== null) return !!__optionDisabled(opt);
if (opt !== null && typeof opt === 'object' && 'disabled' in opt) return !!opt.disabled;
return false;
};
optionId = (index: any) => this.id() + '-opt-' + index;
visibleOptions = () => {
const __options = this.options();
const q = (this.query() || '').trim().toLowerCase();
if (q === '') return __options;
return __options.filter((opt: any) => this.labelOf(opt).toLowerCase().includes(q));
};
isSelected = (opt: any) => {
const v = this.valueOf$local(opt);
const cur = this.value();
if (this.multiple()) return Array.isArray(cur) && cur.includes(v);
return cur === v;
};
resolveInitialActive = () => {
const opts = this.visibleOptions();
const sel = opts.findIndex((o: any) => this.isSelected(o) && !this.disabledOf(o));
if (sel !== -1) return sel;
return opts.findIndex((o: any) => !this.disabledOf(o));
};
applyExpanded = (next: any) => {
if (next && (this.disabled() || this.__rozieCvaDisabled())) return;
if (this.open$local() === next) return;
this.open$local.set(next);
this.activeIndex.set(next ? this.resolveInitialActive() : -1);
this.openChange.emit({
open: next
});
};
open = () => this.applyExpanded(true);
close = () => this.applyExpanded(false);
toggle = () => this.applyExpanded(!this.open$local());
fireChange = (value: any, option: any) => this.change.emit({
value: this.value(),
option
});
select = (opt: any) => {
if (this.disabledOf(opt)) return;
const v = this.valueOf$local(opt);
if (this.multiple()) {
const cur = this.value();
const arr = Array.isArray(cur) ? cur : [];
// Fresh array on every commit — in-place mutation is dropped by the
// React/Solid/Lit/Angular change detectors.
const next = arr.includes(v) ? arr.filter((x: any) => x !== v) : [...arr, v];
this.value.set(next), this.__rozieCvaOnChange(next);
this.fireChange(next, opt);
} else {
this.value.set(v), this.__rozieCvaOnChange(v);
this.fireChange(v, opt);
if (this.closeOnSelect()) {
this.close();
this.focusControl();
}
}
};
clear = () => {
const empty = this.multiple() ? [] : null;
this.value.set(empty), this.__rozieCvaOnChange(empty);
this.query.set('');
this.fireChange(empty, null);
};
nextEnabled = (from: any, dir: any) => {
const opts = this.visibleOptions();
if (opts.length === 0) return -1;
let i = from;
for (let step = 0; step < opts.length; step++) {
i += dir;
if (i < 0) i = opts.length - 1;else if (i >= opts.length) i = 0;
if (!this.disabledOf(opts[i])) return i;
}
return from;
};
move = (dir: any) => {
if (!this.open$local()) {
this.open();
return;
}
const start = this.activeIndex() < 0 ? dir > 0 ? -1 : 0 : this.activeIndex();
this.activeIndex.set(this.nextEnabled(start, dir));
this.scrollActiveIntoView();
};
moveEdge = (toEnd: any) => {
if (!this.open$local()) this.open();
this.activeIndex.set(toEnd ? this.nextEnabled(-1, -1) : this.nextEnabled(-1, 1));
this.scrollActiveIntoView();
};
commitActive = () => {
const __activeIndex = this.activeIndex();
const opts = this.visibleOptions();
if (__activeIndex >= 0 && __activeIndex < opts.length) this.select(opts[__activeIndex]);
};
onTypeahead = (ch: any) => {
if (this.typeTimer !== null) clearTimeout(this.typeTimer);
this.typeBuffer += ch.toLowerCase();
this.typeTimer = setTimeout(() => {
this.typeBuffer = '';
}, 600);
const opts = this.visibleOptions();
const idx = opts.findIndex((o: any) => !this.disabledOf(o) && this.labelOf(o).toLowerCase().startsWith(this.typeBuffer));
if (idx !== -1) {
if (!this.open$local()) this.open();
this.activeIndex.set(idx);
this.scrollActiveIntoView();
}
};
onControlKeyDown = ($event: any) => {
const __open$local = this.open$local();
const key = $event.key;
if (key === 'ArrowDown') {
$event.preventDefault();
this.move(1);
} else if (key === 'ArrowUp') {
$event.preventDefault();
this.move(-1);
} else if (key === 'Home') {
$event.preventDefault();
this.moveEdge(false);
} else if (key === 'End') {
$event.preventDefault();
this.moveEdge(true);
} else if (key === 'Enter') {
if (__open$local) {
$event.preventDefault();
this.commitActive();
}
} else if (key === 'Escape') {
if (__open$local) {
$event.preventDefault();
this.close();
this.focusControl();
}
} else if (key === ' ' || key === 'Spacebar') {
// Space toggles / commits in a select-only host (a button trigger). A
// filter-input host types the literal space into its <input> and does NOT
// route Space through this reducer, so this branch is select-only by use.
$event.preventDefault();
if (!__open$local) this.open();else this.commitActive();
} else if (key === 'Tab') {
if (__open$local) this.close();
} else if (key.length === 1 && !$event.metaKey && !$event.ctrlKey && !$event.altKey) {
this.onTypeahead(key);
}
};
onOptionPointerMove = (index: any) => {
if (this.activeIndex() !== index) this.activeIndex.set(index);
};
virtualItemKey = (i: any) => {
const src = this.windowSource();
return src && src[i] ? src[i].id : undefined;
};
virtualizerOptions = (): any => ({
count: this.windowSource().length,
getScrollElement: () => this.gridScrollEl,
estimateSize: () => this.estimateRowHeight(),
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: this.virtualItemKey,
onChange: () => {
this.windowVer.set(this.windowVer() + 1);
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
this.scheduleRemeasure();
}
});
pinMeasurement = (pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null => this.pinnedMeasurement(pin);
windowedRows = () => {
const __rows = this.rows();
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void this.windowVer();
void this.editVer();
if (!this.virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!this.virtual()) {
const rowList = __rows || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = this.virtualizer.getVirtualItems();
const rowList = __rows || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = this.pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = this.pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
};
padTop = () => {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void this.windowVer();
void this.editVer();
if (!this.virtual() || !this.virtualizer) return 0;
const items = this.virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = this.pinnedEditIndex();
if (pin >= 0) {
const pm = this.pinMeasurement(pin);
const inWindow = this.pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
};
padBottom = () => {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void this.windowVer();
void this.editVer();
if (!this.virtual() || !this.virtualizer) return 0;
const items = this.virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = this.virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = this.pinnedEditIndex();
if (pin >= 0) {
const pm = this.pinMeasurement(pin);
const inWindow = this.pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
};
pmIndexInWindow = (items: any, idx: any) => {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
};
rowIsOutsideWindow = (r: any) => {
if (!this.virtual() || !this.virtualizer) return false;
const items = this.virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
virtualizer: any = null;
virtualizerCleanup: any = null;
gridScrollEl: any = null;
remeasurePending = false;
windowSource = () => this.visibleOptions().map((o: any, i: any) => ({
id: this.valueOf$local(o),
_opt: o,
_i: i
}));
pinnedEditIndex = () => -1;
pinnedMeasurement = (pin: any) => null;
syncRows = () => {
this.rows.set(this.windowSource());
};
scheduleRemeasure = () => {
if (this.remeasurePending) return;
this.remeasurePending = true;
let ranMicro = false;
const microPass = () => {
this.remeasureWindow();
};
const rafPass = () => {
this.remeasurePending = false;
this.remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
};
remeasureWindow = () => {
if (!this.virtualizer || !this.gridScrollEl) return;
if (this.virtualizer.scrollState) return;
const els = this.gridScrollEl.querySelectorAll('.rozie-listbox-option[data-index]');
for (const el of els as any) this.virtualizer.measureElement(el);
};
focusControl = () => {
this.triggerEl()?.nativeElement?.focus();
};
scrollActiveIntoView = () => {
const __activeIndex = this.activeIndex();
if (__activeIndex < 0) return;
if (this.virtual() && this.virtualizer) {
// 'center' (not 'auto'): keep the active option well inside the rendered slice as the
// window scrolls — 'auto' lands it at the viewport edge where the overscan band can
// leave it just-unrendered for a frame on the fine-grained targets (Solid).
this.virtualizer.scrollToIndex(__activeIndex, {
align: 'center'
});
this.scheduleRemeasure();
return;
}
if (!this.listEl()?.nativeElement) return;
const el = this.listEl()!.nativeElement.querySelector('#' + CSS.escape(this.optionId(__activeIndex)));
el?.scrollIntoView({
block: 'nearest'
});
};
kickWindow = (attempts: any) => {
if (!this.virtualizer) return;
this.gridScrollEl = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rozie-listbox-list') : this.gridScrollEl;
// Only re-feed the count from a NON-EMPTY source: on React these rAF closures capture
// stale (mount-time, empty) props, so feeding here would CLOBBER the $watch's correct
// count back to 0. The $watch (fresh useEffect props) owns React's count; the kick owns
// the Solid/Lit scroll-element re-attach + the deferred windowVer re-derive.
if (this.windowSource().length > 0) {
this.syncRows();
this.virtualizer.setOptions(this.virtualizerOptions());
}
this.virtualizer._willUpdate();
this.windowVer.set(this.windowVer() + 1);
this.remeasureWindow();
if (this.windowedRows().length === 0 && attempts > 0) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => this.kickWindow(attempts - 1));else setTimeout(() => this.kickWindow(attempts - 1), 16);
}
};
private __rozieCvaOnChange: (v: unknown) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: unknown | null): void {
this.value.set(v ?? null);
}
registerOnChange(fn: (v: unknown) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
static ngTemplateContextGuard(
_dir: Listbox,
_ctx: unknown,
): _ctx is SelectedCtx | OptionCtx | EmptyCtx {
return true;
}
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
protected get __style() {
const __maxHeight = this.maxHeight();
return (this.open$local() ? '' : 'display:none;') + (__maxHeight ? 'height:' + __maxHeight + ';max-height:' + __maxHeight + ';overflow-y:auto;--rozie-listbox-max-height:' + __maxHeight : 'overflow-y:auto');
}
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default Listbox;tsx
import type { JSX } from 'solid-js';
import { For, Show, createEffect, createMemo, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, createOutsideClick, parseInlineStyle, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally
// (a peer dep); every RUNTIME reference sits behind `if ($props.virtual)` / a
// `virtualizer` guard so the non-virtual emitted path executes none of it
// (byte-identical-off).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
__rozieInjectStyle('Listbox-b576227a', `.rozie-listbox[data-rozie-s-b576227a] {
position: relative;
display: inline-block;
min-width: var(--rozie-listbox-min-width, 12rem);
font: var(--rozie-listbox-font, inherit);
}
.rozie-listbox-control[data-rozie-s-b576227a] { display: block; }
.rozie-listbox-input[data-rozie-s-b576227a],
.rozie-listbox-trigger[data-rozie-s-b576227a] {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
padding: var(--rozie-listbox-control-padding, 0.5rem 0.75rem);
font: inherit;
text-align: left;
background: var(--rozie-listbox-bg, #fff);
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-border, rgba(0, 0, 0, 0.2));
border-radius: var(--rozie-listbox-radius, 6px);
cursor: pointer;
}
.rozie-listbox-input[data-rozie-s-b576227a] { cursor: text; }
.rozie-listbox-input[data-rozie-s-b576227a]:focus-visible,
.rozie-listbox-input[data-rozie-s-b576227a]:focus,
.rozie-listbox-trigger[data-rozie-s-b576227a]:focus-visible,
.rozie-listbox-trigger[data-rozie-s-b576227a]:focus {
outline: var(--rozie-listbox-ring-width, 2px) solid var(--rozie-listbox-ring, var(--rozie-listbox-accent, #0066cc));
outline-offset: var(--rozie-listbox-ring-offset, 1px);
}
.rozie-listbox-disabled[data-rozie-s-b576227a] { opacity: var(--rozie-listbox-disabled-opacity, 0.6); pointer-events: none; }
.rozie-listbox-placeholder[data-rozie-s-b576227a] { color: var(--rozie-listbox-placeholder, rgba(0, 0, 0, 0.45)); }
.rozie-listbox-arrow[data-rozie-s-b576227a] {
font-size: 0.75em;
color: var(--rozie-listbox-arrow-color, currentColor);
opacity: var(--rozie-listbox-arrow-opacity, 0.7);
}
.rozie-listbox-list[data-rozie-s-b576227a] {
position: absolute;
z-index: var(--rozie-listbox-z, 1000);
top: calc(100% + var(--rozie-listbox-popup-offset, 4px));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-listbox-popup-padding, 0.25rem);
max-height: var(--rozie-listbox-max-height, 16rem);
overflow-y: auto;
list-style: none;
background: var(--rozie-listbox-popup-bg, var(--rozie-listbox-bg, #fff));
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-popup-border, var(--rozie-listbox-border, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-listbox-popup-radius, var(--rozie-listbox-radius, 6px));
box-shadow: var(--rozie-listbox-shadow, 0 6px 24px rgba(0, 0, 0, 0.12));
}
.rozie-listbox-inline[data-rozie-s-b576227a] {
display: block;
width: 100%;
}
.rozie-listbox-inline[data-rozie-s-b576227a] .rozie-listbox-list[data-rozie-s-b576227a] {
position: static;
margin-top: var(--rozie-listbox-popup-offset, 4px);
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-listbox-option[data-rozie-s-b576227a] {
padding: var(--rozie-listbox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-listbox-option-radius, 4px);
color: var(--rozie-listbox-option-fg, inherit);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
}
.rozie-listbox-option.is-active[data-rozie-s-b576227a] {
background: var(--rozie-listbox-active-bg, rgba(0, 102, 204, 0.12));
color: var(--rozie-listbox-active-fg, inherit);
}
.rozie-listbox-option.is-selected[data-rozie-s-b576227a] {
background: var(--rozie-listbox-selected-bg, transparent);
color: var(--rozie-listbox-selected-fg, inherit);
font-weight: var(--rozie-listbox-selected-weight, 600);
}
.rozie-listbox-option.is-selected[data-rozie-s-b576227a]::after {
content: var(--rozie-listbox-check, '✓');
color: var(--rozie-listbox-check-color, var(--rozie-listbox-accent, #0066cc));
}
.rozie-listbox-option.is-disabled[data-rozie-s-b576227a] { opacity: var(--rozie-listbox-disabled-opacity, 0.45); cursor: not-allowed; }
.rozie-listbox-empty[data-rozie-s-b576227a] { padding: var(--rozie-listbox-option-padding, 0.5rem 0.6rem); color: var(--rozie-listbox-empty-fg, rgba(0, 0, 0, 0.5)); }
.rozie-listbox-spacer[data-rozie-s-b576227a] { margin: 0; padding: 0; border: 0; flex: none; }`);
interface SelectedSlotCtx { selected: any; value: any; }
interface OptionSlotCtx { option: any; index: any; active: any; selected: any; disabled: any; }
interface EmptySlotCtx { query: any; }
interface ListboxProps {
/**
* The option set. Each entry is either a primitive (`string`/`number`) or an object; objects resolve their label, value, and disabled state via the `option*` resolver props, falling back to `.label` / `.value` / `.disabled`.
*/
options?: any[];
/**
* The selected value (two-way `r-model`) — a scalar in single-select, an array of values in multi-select. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Listbox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
* @example
* <Listbox r-model:value="fruit" :options="fruits" />
*/
value?: (unknown) | null;
defaultValue?: (unknown) | null;
onValueChange?: (value: (unknown) | null) => void;
/**
* Enable multi-select: `value` becomes an array, selecting an option toggles its membership, and the popup stays open after each commit.
*/
multiple?: boolean;
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the listbox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
inline?: boolean;
/**
* Disable the control entirely. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Placeholder text shown in the empty control.
*/
placeholder?: string;
/**
* Close the popup after a single-select commit. Defaults `true`; multi-select keeps the popup open regardless of this setting.
*/
closeOnSelect?: boolean;
/**
* Resolver override for an object option's display label — `(option) => string`. Falls back to the option's `.label` property.
*/
optionLabel?: ((...args: unknown[]) => unknown) | null;
/**
* Resolver override for an object option's committed value — `(option) => value`. Falls back to the option's `.value` property.
*/
optionValue?: ((...args: unknown[]) => unknown) | null;
/**
* Resolver override marking an option non-selectable — `(option) => boolean`. Falls back to the option's `.disabled` property.
*/
optionDisabled?: ((...args: unknown[]) => unknown) | null;
/**
* Stable id base for the ARIA wiring (the listbox id, per-option ids, and `aria-activedescendant`). Give each instance on a page a distinct id so these references stay unique.
*/
id?: string;
/**
* Accessible name for the control when there is no visible `<label for>` pointing at its `id` (`aria-label`).
*/
ariaLabel?: (string) | null;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling list (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed listbox. Pair with `inline` + `maxHeight` so the windowed scroll container is bounded.
*/
virtual?: boolean;
/**
* Estimated option row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight?: number;
/**
* A CSS length string bounding the list scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-listbox-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight?: string;
onOpenChange?: (...args: unknown[]) => void;
onChange?: (...args: unknown[]) => void;
selectedSlot?: (ctx: SelectedSlotCtx) => JSX.Element;
optionSlot?: (ctx: OptionSlotCtx) => JSX.Element;
emptySlot?: (ctx: EmptySlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: ListboxHandle) => void;
}
export interface ListboxHandle {
open: (...args: any[]) => any;
close: (...args: any[]) => any;
toggle: (...args: any[]) => any;
clear: (...args: any[]) => any;
focusControl: (...args: any[]) => any;
}
export default function Listbox(_props: ListboxProps): JSX.Element {
const _merged = mergeProps({ options: (() => [])(), multiple: false, inline: false, disabled: false, placeholder: '', closeOnSelect: true, optionLabel: null, optionValue: null, optionDisabled: null, id: 'rozie-listbox', ariaLabel: null, virtual: false, estimateRowHeight: 36, maxHeight: '' }, _props);
const [local, attrs] = splitProps(_merged, ['options', 'value', 'multiple', 'inline', 'disabled', 'placeholder', 'closeOnSelect', 'optionLabel', 'optionValue', 'optionDisabled', 'id', 'ariaLabel', 'virtual', 'estimateRowHeight', 'maxHeight', 'ref']);
onMount(() => { local.ref?.({ open, close, toggle, clear, focusControl }); });
const [value, setValue] = createControllableSignal<unknown>(_props as unknown as Record<string, unknown>, 'value', null);
const [open$local, setOpen$local] = createSignal(false);
const [activeIndex, setActiveIndex] = createSignal(-1);
const [query, setQuery] = createSignal('');
const [rows, setRows] = createSignal<any[]>([]);
const [windowVer, setWindowVer] = createSignal(0);
const [editVer, setEditVer] = createSignal(0);
const selectedLabel = createMemo(() => {
const cur = value();
if (local.multiple) {
// Read the model value into a local before narrowing: `$props.value` lowers
// to a `value()` accessor on Solid, and Array.isArray() can't narrow two
// separate calls — narrowing one stable local works on every target.
const arr = Array.isArray(cur) ? cur : [];
if (arr.length === 0) return '';
return local.options.filter((o: any) => arr.includes(valueOf(o))).map(labelOf).join(', ');
}
const match = local.options.find((o: any) => valueOf(o) === cur);
return match === undefined ? '' : labelOf(match);
});
const activeDescendant = createMemo(() => {
if (!open$local() || activeIndex() < 0) return null;
return optionId(activeIndex());
});
onMount(() => {
syncRows();
if (local.virtual) {
// The list renders at mount when virtual, so the .rozie-listbox-list scroll container
// exists here. Capture it via $el.querySelector (the data-table gridScrollEl precedent,
// proven ×6 incl Lit shadow + Solid) — $refs on a conditionally-rendered node is null on
// Solid/Lit, which leaves the virtualizer with no scroll element.
gridScrollEl = __rozieRootRef! ? __rozieRootRef!.querySelector('.rozie-listbox-list') : null;
virtualizer = new Virtualizer(virtualizerOptions());
virtualizerCleanup = virtualizer._didMount();
setWindowVer(windowVer() + 1);
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(8));else setTimeout(() => kickWindow(8), 0);
}
});
onCleanup(() => {
if (typeTimer !== null) clearTimeout(typeTimer);
// Tear down the virtualizer's scroll-element ResizeObserver (no-op when virtual off).
if (virtualizerCleanup) virtualizerCleanup();
});
createEffect(on(() => (() => (local.options ? local.options.length : 0) + '|' + query())(), (v) => untrack(() => (() => {
syncRows();
if (local.virtual && virtualizer) {
gridScrollEl = __rozieRootRef! ? __rozieRootRef!.querySelector('.rozie-listbox-list') : gridScrollEl;
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
setWindowVer(windowVer() + 1);
scheduleRemeasure();
}
})()), { defer: true }));
let controlElRef: HTMLElement | null = null;
let triggerElRef: HTMLElement | null = null;
let listElRef: HTMLElement | null = null;
let __rozieRootRef: HTMLElement | null = null;
// Type-ahead buffer for the select-only listbox trigger. Module-scope
// `let`s reassigned from handlers → the React emitter hoists them to `useRef`
// so they persist across renders (the setup-once guarantee); no-op elsewhere.
// They STAY in this host (not the shared spine) per the A==B rule: reassigned
// module-`let`s + sigils live in the host; the partial only closes over them.
let typeBuffer = '';
let typeTimer: any = null;
// ---- shared list spine (P2: @rozie-ui/headless-core/listCore.rzts) ------
// The option resolvers, client filter, enabled-index navigation, the keyboard
// reducer, type-ahead, single+multi selection, open/close state, and
// activeDescendant derivation now live in the shared, focus-/input-mode
// parameterized list spine. It is a compile-time `.rzts` script-partial: it
// dissolves into this leaf via inlineScriptPartials() before IR lowering (zero
// runtime dep). Listbox consumes it in focus-model `activedescendant` +
// input-mode `select-only` + multi + type-ahead. The spine closes over this
// host's pieces by convention: the reassigned module-`let`s typeBuffer/typeTimer
// (above) and the impure ref fns focusControl/scrollActiveIntoView (below).
// ══ Shared headless LIST SPINE (Phase 64, D-06) — the target-agnostic list-core bridge ══
// Lifted verbatim from Listbox.rozie's <script> (the monolithic pure-Rozie list logic). This
// partial holds ONLY the PURE list spine — option resolvers, the client-side filter, enabled-index
// navigation, the arrow/home/end/enter/escape/space/tab keyboard reducer, type-ahead, single+multi
// selection, open/close state, and activeDescendant derivation. It is a compile-time `.rzts`
// script-partial: it dissolves into each consumer's compiled leaf via inlineScriptPartials() before
// IR lowering — leaving zero runtime dependency (the 64-01-proven cross-package bare-specifier path).
//
// ── PARAMETERIZATION (D-06) ──────────────────────────────────────────────────────────────────
// The spine is parameterized BY HOST CONVENTION (the same implicit by-convention mixin contract
// windowing.rzts uses) along two axes:
// - focus-model: `activedescendant` | `roving`. Both list families default to `activedescendant`
// (what they use today): the highlighted option is tracked virtually via `activeDescendant`
// (an option id) while DOM focus stays on the control. `roving` (real per-option tabindex
// focus) is SUPPORTED-BUT-UNUSED — no focus rewrite is forced here; a roving host would supply
// its own focus mover. The `activeDescendant` / `optionId` derivation below IS the
// activedescendant model.
// - input-mode: `select-only` (Listbox — a button trigger + type-ahead) | `filter-input`
// (Combobox — a text <input> that filters by the typed query). The mode is by HOST CONVENTION,
// NOT a discriminant prop (P3 retired the Listbox `combobox`/`filterable` props): a select-only
// host never writes `$data.query`, so `visibleOptions` is the identity path for it and the
// printable-char branch of the reducer feeds type-ahead; a filter-input host writes `$data.query`
// from its <input>, so `visibleOptions` substring-filters and `onInput` drives the query.
//
// ── HOST CONTRACT (symbols the consuming host MUST define before importing) ────────────────────
// - the reassigned module-`let`s `typeBuffer` / `typeTimer` — type-ahead scratch state. They are
// reassigned from handlers → the React emitter hoists them to `useRef` (the setup-once
// guarantee), so per the A==B playbook rule they STAY IN THE HOST; this partial only closes
// over them (in `onTypeahead`).
// - `focusControl()` / `scrollActiveIntoView()` — impure ref-reading functions (they touch the
// control / list ref elements, which are post-mount-only per ROZ123), so they are per-consumer
// HOST functions; this partial only closes over them (it reads NO refs itself).
// - the option set + form surface (`$props.options` / `$props.value` (model) / `$props.multiple` /
// `$props.id` / `$props.optionLabel` / `$props.optionValue` / `$props.optionDisabled` /
// `$props.closeOnSelect` / `$props.disabled`) and the reactive state (`$data.open` /
// `$data.activeIndex` / `$data.query`). Input-mode is by convention (the host's <input> writing
// `$data.query`), NOT a discriminant prop.
// ---- option resolvers --------------------------------------------------
function labelOf(opt: any) {
if (local.optionLabel !== null) return local.optionLabel(opt);
if (opt !== null && typeof opt === 'object' && 'label' in opt) return opt.label;
return String(opt);
}
function valueOf(opt: any) {
if (local.optionValue !== null) return local.optionValue(opt);
if (opt !== null && typeof opt === 'object' && 'value' in opt) return opt.value;
return opt;
}
function disabledOf(opt: any) {
if (local.optionDisabled !== null) return !!local.optionDisabled(opt);
if (opt !== null && typeof opt === 'object' && 'disabled' in opt) return !!opt.disabled;
return false;
}
function optionId(index: any) {
return local.id + '-opt-' + index;
}
// ---- derived state -----------------------------------------------------
// The visible option list: identity in select-only / non-filtering mode,
// a case-insensitive substring filter when a combobox query is present.
// A plain function (not `$computed`) so it reads uniformly across all six
// targets — a `$computed` is a value on React but an accessor on Solid, so
// aliasing it to a local (`const opts = visibleOptions()`) diverges; calling a
// plain function is identical everywhere.
function visibleOptions() {
const q = (query() || '').trim().toLowerCase();
if (q === '') return local.options;
return local.options.filter((opt: any) => labelOf(opt).toLowerCase().includes(q));
}
// The label shown in the (select-only) trigger when closed. A real `$computed`
// — read bare in the template, never aliased in script, so the per-target
// accessor form stays uniform.
// Is a given option currently selected? Multi compares array membership.
function isSelected(opt: any) {
const v = valueOf(opt);
const cur = value();
if (local.multiple) return Array.isArray(cur) && cur.includes(v);
return cur === v;
}
// First enabled visible index, preferring the currently-selected option.
function resolveInitialActive() {
const opts = visibleOptions();
const sel = opts.findIndex((o: any) => isSelected(o) && !disabledOf(o));
if (sel !== -1) return sel;
return opts.findIndex((o: any) => !disabledOf(o));
}
// ---- open / close ------------------------------------------------------
// Single open-state mutator → the ONLY `$emit('open-change')` site, so the
// React prop-destructure for `onOpenChange` hoists exactly once.
function applyExpanded(next: any) {
if (next && local.disabled) return;
if (open$local() === next) return;
setOpen$local(next);
setActiveIndex(next ? resolveInitialActive() : -1);
_props.onOpenChange?.({
open: next
});
}
function open() {
return applyExpanded(true);
}
function close() {
return applyExpanded(false);
}
function toggle() {
return applyExpanded(!open$local());
}
// ---- selection ---------------------------------------------------------
// Single `$emit('change')` site (called from both select + clear).
function fireChange(value: any, option: any) {
return _props.onChange?.({
value,
option
});
}
function select(opt: any) {
if (disabledOf(opt)) return;
const v = valueOf(opt);
if (local.multiple) {
const cur = value();
const arr = Array.isArray(cur) ? cur : [];
// Fresh array on every commit — in-place mutation is dropped by the
// React/Solid/Lit/Angular change detectors.
const next = arr.includes(v) ? arr.filter((x: any) => x !== v) : [...arr, v];
setValue(next);
fireChange(next, opt);
} else {
setValue(v);
fireChange(v, opt);
if (local.closeOnSelect) {
close();
focusControl();
}
}
}
function clear() {
const empty = local.multiple ? [] : null;
setValue(empty);
setQuery('');
fireChange(empty, null);
}
// ---- keyboard navigation over the VISIBLE list -------------------------
function nextEnabled(from: any, dir: any) {
const opts = visibleOptions();
if (opts.length === 0) return -1;
let i = from;
for (let step = 0; step < opts.length; step++) {
i += dir;
if (i < 0) i = opts.length - 1;else if (i >= opts.length) i = 0;
if (!disabledOf(opts[i])) return i;
}
return from;
}
function move(dir: any) {
if (!open$local()) {
open();
return;
}
const start = activeIndex() < 0 ? dir > 0 ? -1 : 0 : activeIndex();
setActiveIndex(nextEnabled(start, dir));
scrollActiveIntoView();
}
function moveEdge(toEnd: any) {
if (!open$local()) open();
setActiveIndex(toEnd ? nextEnabled(-1, -1) : nextEnabled(-1, 1));
scrollActiveIntoView();
}
function commitActive() {
const opts = visibleOptions();
if (activeIndex() >= 0 && activeIndex() < opts.length) select(opts[activeIndex()]);
}
// Type-ahead for select-only listboxes: accumulate keystrokes and jump to the
// first option whose label starts with the buffer.
function onTypeahead(ch: any) {
if (typeTimer !== null) clearTimeout(typeTimer);
typeBuffer += ch.toLowerCase();
typeTimer = setTimeout(() => {
typeBuffer = '';
}, 600);
const opts = visibleOptions();
const idx = opts.findIndex((o: any) => !disabledOf(o) && labelOf(o).toLowerCase().startsWith(typeBuffer));
if (idx !== -1) {
if (!open$local()) open();
setActiveIndex(idx);
scrollActiveIntoView();
}
}
// Key handler shared by the trigger and the combobox input. The printable-
// character branch is reached only in select-only mode (the combobox input
// types through @input).
function onControlKeyDown($event: any) {
const key = $event.key;
if (key === 'ArrowDown') {
$event.preventDefault();
move(1);
} else if (key === 'ArrowUp') {
$event.preventDefault();
move(-1);
} else if (key === 'Home') {
$event.preventDefault();
moveEdge(false);
} else if (key === 'End') {
$event.preventDefault();
moveEdge(true);
} else if (key === 'Enter') {
if (open$local()) {
$event.preventDefault();
commitActive();
}
} else if (key === 'Escape') {
if (open$local()) {
$event.preventDefault();
close();
focusControl();
}
} else if (key === ' ' || key === 'Spacebar') {
// Space toggles / commits in a select-only host (a button trigger). A
// filter-input host types the literal space into its <input> and does NOT
// route Space through this reducer, so this branch is select-only by use.
$event.preventDefault();
if (!open$local()) open();else commitActive();
} else if (key === 'Tab') {
if (open$local()) close();
} else if (key.length === 1 && !$event.metaKey && !$event.ctrlKey && !$event.altKey) {
onTypeahead(key);
}
}
// Combobox input handler: keep the popup open while typing, reset the active
// highlight to the first match, and surface the query for remote filtering.
// Pointer hover sets the virtual highlight (matches native <select> feel).
function onOptionPointerMove(index: any) {
if (activeIndex() !== index) setActiveIndex(index);
}
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
function virtualItemKey(i: any) {
const src = windowSource();
return src && src[i] ? src[i].id : undefined;
}
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
function virtualizerOptions(): any {
return {
count: windowSource().length,
getScrollElement: () => gridScrollEl,
estimateSize: () => local.estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
setWindowVer(windowVer() + 1);
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
scheduleRemeasure();
}
};
}
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
function pinMeasurement(pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null {
return pinnedMeasurement(pin);
}
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
function windowedRows() {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void windowVer();
void editVer();
if (!virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!local.virtual) {
const rowList = rows() || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = virtualizer.getVirtualItems();
const rowList = rows() || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
}
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
function padTop() {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void windowVer();
void editVer();
if (!local.virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
}
function padBottom() {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void windowVer();
void editVer();
if (!local.virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
}
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
function pmIndexInWindow(items: any, idx: any) {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
}
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
function rowIsOutsideWindow(r: any) {
if (!local.virtual || !virtualizer) return false;
const items = virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
}
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
let remeasurePending = false;
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// set. CR-02: the shared windowing contract requires each row to carry a STABLE `.id`
// (windowing.rzts virtualItemKey reads src[i].id, and the windowed template keys on
// wr.row.id). A raw Listbox option is a primitive or a bare { label, value, disabled }
// — NOT guaranteed to have `.id` — so an unwrapped raw set keyed on wr.row.id collapses
// every framework :key (and every virtual-core measurement key) to `undefined`, which
// recycles the wrong DOM node as the window scrolls. Wrap each option into an id-bearing
// row the way the sibling Combobox's filteredOptions() does — `id` is the resolved
// value, `_opt` the original option (read via wr.row._opt in the windowed template),
// `_i` the source index. Kept === $data.rows so the math's rowList[vi.index] resolves to
// the same wrapped row the count windows over.
function windowSource() {
return visibleOptions().map((o: any, i: any) => ({
id: valueOf(o),
_opt: o,
_i: i
}));
}
// D-05 NO-OP PIN HOOK (defined in THIS host, NOT the shared partial — keeps data-table
// A==B intact). The shared windowedRows/padTop/padBottom call pinnedEditIndex()/
// pinnedMeasurement() UNGUARDED by convention; a listbox has no edit-pinning, so these
// reduce the pin union (-1 → never unioned) and the spacer subtraction (null → identity)
// to a no-op. They MUST exist or the by-convention call ReferenceErrors at mount.
function pinnedEditIndex() {
return -1;
}
function pinnedMeasurement(pin: any) {
return null;
}
// Keep $data.rows === windowSource() so the windowing math indexes the live option set.
function syncRows() {
setRows(windowSource());
}
// Defer remeasureWindow() until AFTER the framework commits the recycled window
// (onChange fires BEFORE React/Solid commit). TWO deferred passes (microtask THEN rAF)
// behind one in-flight flag (the data-table virtualization.rzts:46-56 pattern, copied
// per-consumer per D-04/D-09): the microtask catches Solid's <For> / Svelte's {#each}
// SYNCHRONOUS commit (the Phase 63 Solid under-convergence hazard — D-09 rAF-defer
// budget), the rAF catches React's async commit. measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
function scheduleRemeasure() {
if (remeasurePending) return;
remeasurePending = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
const rafPass = () => {
remeasurePending = false;
remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
}
// measureElement sweep: hand every rendered windowed option to the virtualizer so its
// true (variable) height is observed (virtual-core measures ONLY nodes passed to
// measureElement, keyed by the data-index attribute). Bails during a programmatic
// scroll (scrollToIndex) so a measure can't starve the scroll target.
function remeasureWindow() {
if (!virtualizer || !gridScrollEl) return;
if (virtualizer.scrollState) return;
const els = gridScrollEl.querySelectorAll('.rozie-listbox-option[data-index]');
for (const el of els as any) virtualizer.measureElement(el);
}
// ---- focus / scroll helpers (post-mount $refs only) --------------------
// Impure ($refs) → per the ROZ123 + A==B rules they stay in the host (the spine
// only closes over them). Named `focusControl` (not `focus`): a `focus` $expose
// verb would override the inherited HTMLElement.focus method on the Lit element.
function focusControl() {
triggerElRef?.focus();
}
// Keep the active option visible inside the scrolling listbox. Reads $refs in
// a post-mount callback only (never eagerly — ROZ123). When windowing, route through
// the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window is
// scrolled into view (the windowed-arrow-nav seam); else the native scrollIntoView.
function scrollActiveIntoView() {
if (activeIndex() < 0) return;
if (local.virtual && virtualizer) {
// 'center' (not 'auto'): keep the active option well inside the rendered slice as the
// window scrolls — 'auto' lands it at the viewport edge where the overscan band can
// leave it just-unrendered for a frame on the fine-grained targets (Solid).
virtualizer.scrollToIndex(activeIndex(), {
align: 'center'
});
scheduleRemeasure();
return;
}
if (!listElRef) return;
const el = listElRef.querySelector('#' + CSS.escape(optionId(activeIndex())));
el?.scrollIntoView({
block: 'nearest'
});
}
// ---- windowing lifecycle (post-mount; ONLY when virtual) ----------------
// kickWindow: the cross-target first-paint settle. Re-captures the LIVE scroll element,
// re-feeds the CURRENT option count into the virtualizer, re-attaches its rect observer
// (_willUpdate), and bumps the windowVer signal so the windowed <For>/{#each}/repeat
// re-derives. Retried over a few frames because (a) virtual-core measures the scroll rect
// asynchronously (D-09 Solid rAF-defer — a synchronous kick sees rectH 0 → empty window),
// (b) Solid/Lit recreate the list node between mount and first commit (leaving virtual-core's
// scrollElement stale), and (c) the consumer often seeds options AFTER the listbox mounts
// (Lit/React), so the count must be re-read once the prop propagates. Stops once the window
// paints (or attempts run out) — idempotent + loop-free.
function kickWindow(attempts: any) {
if (!virtualizer) return;
gridScrollEl = __rozieRootRef! ? __rozieRootRef!.querySelector('.rozie-listbox-list') : gridScrollEl;
// Only re-feed the count from a NON-EMPTY source: on React these rAF closures capture
// stale (mount-time, empty) props, so feeding here would CLOBBER the $watch's correct
// count back to 0. The $watch (fresh useEffect props) owns React's count; the kick owns
// the Solid/Lit scroll-element re-attach + the deferred windowVer re-derive.
if (windowSource().length > 0) {
syncRows();
virtualizer.setOptions(virtualizerOptions());
}
virtualizer._willUpdate();
setWindowVer(windowVer() + 1);
remeasureWindow();
if (windowedRows().length === 0 && attempts > 0) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => kickWindow(attempts - 1));else setTimeout(() => kickWindow(attempts - 1), 16);
}
}
createOutsideClick(
[() => controlElRef, () => listElRef],
close,
() => open$local(),
);
return (
<>
<div ref={(el) => { __rozieRootRef = el as HTMLElement; }} {...attrs} class={"rozie-listbox" + " " + rozieClass({ 'rozie-listbox-open': open$local(), 'rozie-listbox-disabled': local.disabled, 'rozie-listbox-inline': local.inline }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-b576227a="">
<div class={"rozie-listbox-control"} ref={(el) => { controlElRef = el as HTMLElement; }} data-rozie-s-b576227a="">
<button type="button" role="combobox" aria-haspopup="listbox" aria-expanded={open$local()} aria-controls={rozieAttr(local.id + '-list')} aria-activedescendant={rozieAttr(activeDescendant())} aria-label={rozieAttr(local.ariaLabel)} ref={(el) => { triggerElRef = el as HTMLElement; }} class={"rozie-listbox-trigger"} disabled={local.disabled} onClick={toggle} onKeyDown={($event) => { onControlKeyDown($event); }} data-rozie-s-b576227a="">
{(_props.selectedSlot ?? _props.slots?.['selected'])?.({ selected: selectedLabel(), value: value() }) ?? <Show when={selectedLabel()} fallback={<span class={"rozie-listbox-placeholder"} data-rozie-s-b576227a="">{local.placeholder}</span>}><span class={"rozie-listbox-selected"} data-rozie-s-b576227a="">{rozieDisplay(selectedLabel())}</span></Show>}
<span class={"rozie-listbox-arrow"} aria-hidden="true" data-rozie-s-b576227a="">▾</span>
</button>
</div>
{<Show when={open$local() && !local.virtual}><div ref={(el) => { listElRef = el as HTMLElement; }} class={"rozie-listbox-list"} role="listbox" id={rozieAttr(local.id + '-list')} aria-label={rozieAttr(local.ariaLabel)} aria-multiselectable={local.multiple} data-rozie-s-b576227a="">
<For each={visibleOptions()}>{(opt, index) => <div role="option" aria-selected={!!isSelected(opt)} aria-disabled={!!disabledOf(opt)} id={rozieAttr(optionId(index()))} class={"rozie-listbox-option" + " " + rozieClass({ 'is-active': activeIndex() === index(), 'is-selected': isSelected(opt), 'is-disabled': disabledOf(opt) })} onClick={($event) => { select(opt); }} onMouseMove={($event) => { onOptionPointerMove(index()); }} data-rozie-s-b576227a="">
{(_props.optionSlot ?? _props.slots?.['option'])?.({ option: opt, index: index(), active: activeIndex() === index(), selected: isSelected(opt), disabled: disabledOf(opt) }) ?? rozieDisplay(labelOf(opt))}
</div>}</For>
{<Show when={visibleOptions().length === 0}><div class={"rozie-listbox-empty"} role="presentation" data-rozie-s-b576227a="">
{(_props.emptySlot ?? _props.slots?.['empty'])?.({ query: query() }) ?? "No options"}
</div></Show>}</div></Show>}{<Show when={local.virtual}><div ref={(el) => { listElRef = el as HTMLElement; }} class={"rozie-listbox-list rozie-listbox-list--virtual"} role="listbox" id={rozieAttr(local.id + '-list')} aria-label={rozieAttr(local.ariaLabel)} aria-multiselectable={local.multiple} style={parseInlineStyle((open$local() ? '' : 'display:none;') + (local.maxHeight ? 'height:' + local.maxHeight + ';max-height:' + local.maxHeight + ';overflow-y:auto;--rozie-listbox-max-height:' + local.maxHeight : 'overflow-y:auto'))} data-rozie-s-b576227a="">
<div class={"rozie-listbox-spacer"} aria-hidden="true" style={parseInlineStyle('height:' + padTop() + 'px')} data-rozie-s-b576227a="" />
<For each={windowedRows()}>{(wr) => <div data-index={rozieAttr(wr.vi.index)} role="option" aria-selected={!!isSelected(wr.row._opt)} aria-disabled={!!disabledOf(wr.row._opt)} id={rozieAttr(optionId(wr.vi.index))} class={"rozie-listbox-option" + " " + rozieClass({ 'is-active': activeIndex() === wr.vi.index, 'is-selected': isSelected(wr.row._opt), 'is-disabled': disabledOf(wr.row._opt) })} onClick={($event) => { select(wr.row._opt); }} onMouseMove={($event) => { onOptionPointerMove(wr.vi.index); }} data-rozie-s-b576227a="">
{(_props.optionSlot ?? _props.slots?.['option'])?.({ option: wr.row._opt, index: wr.vi.index, active: activeIndex() === wr.vi.index, selected: isSelected(wr.row._opt), disabled: disabledOf(wr.row._opt) }) ?? rozieDisplay(labelOf(wr.row._opt))}
</div>}</For>
<div class={"rozie-listbox-spacer"} aria-hidden="true" style={parseInlineStyle('height:' + padBottom() + 'px')} data-rozie-s-b576227a="" />
{<Show when={windowSource().length === 0}><div class={"rozie-listbox-empty"} role="presentation" data-rozie-s-b576227a="">
{(_props.emptySlot ?? _props.slots?.['empty'])?.({ query: query() }) ?? "No options"}
</div></Show>}</div></Show>}</div>
</>
);
}ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { attachOutsideClickListener, createLitControllableProperty, rozieAttr, rozieDisplay, rozieListeners, rozieSpread, rozieStyle } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally
// (a peer dep); every RUNTIME reference sits behind `if ($props.virtual)` / a
// `virtualizer` guard so the non-virtual emitted path executes none of it
// (byte-identical-off).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// Windowing instance state (the `let table` precedent — React hoists reassigned
// module-`let`s to useRef; do NOT const). NULL until $onMount, and ONLY constructed
// when $props.virtual. gridScrollEl is the captured .rozie-listbox-list scroll div the
// virtualizer observes; remeasurePending dedupes the deferred sweep.
interface RozieSelectedSlotCtx {
selected: unknown;
value: unknown;
}
interface RozieOptionSlotCtx {
option: unknown;
index: unknown;
active: unknown;
selected: unknown;
disabled: unknown;
}
interface RozieEmptySlotCtx {
query: unknown;
}
@customElement('rozie-listbox')
export default class Listbox extends SignalWatcher(LitElement) {
static styles = css`
.rozie-listbox[data-rozie-s-b576227a] {
position: relative;
display: inline-block;
min-width: var(--rozie-listbox-min-width, 12rem);
font: var(--rozie-listbox-font, inherit);
}
.rozie-listbox-control[data-rozie-s-b576227a] { display: block; }
.rozie-listbox-input[data-rozie-s-b576227a],
.rozie-listbox-trigger[data-rozie-s-b576227a] {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
padding: var(--rozie-listbox-control-padding, 0.5rem 0.75rem);
font: inherit;
text-align: left;
background: var(--rozie-listbox-bg, #fff);
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-border, rgba(0, 0, 0, 0.2));
border-radius: var(--rozie-listbox-radius, 6px);
cursor: pointer;
}
.rozie-listbox-input[data-rozie-s-b576227a] { cursor: text; }
.rozie-listbox-input[data-rozie-s-b576227a]:focus-visible,
.rozie-listbox-input[data-rozie-s-b576227a]:focus,
.rozie-listbox-trigger[data-rozie-s-b576227a]:focus-visible,
.rozie-listbox-trigger[data-rozie-s-b576227a]:focus {
outline: var(--rozie-listbox-ring-width, 2px) solid var(--rozie-listbox-ring, var(--rozie-listbox-accent, #0066cc));
outline-offset: var(--rozie-listbox-ring-offset, 1px);
}
.rozie-listbox-disabled[data-rozie-s-b576227a] { opacity: var(--rozie-listbox-disabled-opacity, 0.6); pointer-events: none; }
.rozie-listbox-placeholder[data-rozie-s-b576227a] { color: var(--rozie-listbox-placeholder, rgba(0, 0, 0, 0.45)); }
.rozie-listbox-arrow[data-rozie-s-b576227a] {
font-size: 0.75em;
color: var(--rozie-listbox-arrow-color, currentColor);
opacity: var(--rozie-listbox-arrow-opacity, 0.7);
}
.rozie-listbox-list[data-rozie-s-b576227a] {
position: absolute;
z-index: var(--rozie-listbox-z, 1000);
top: calc(100% + var(--rozie-listbox-popup-offset, 4px));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-listbox-popup-padding, 0.25rem);
max-height: var(--rozie-listbox-max-height, 16rem);
overflow-y: auto;
list-style: none;
background: var(--rozie-listbox-popup-bg, var(--rozie-listbox-bg, #fff));
color: var(--rozie-listbox-fg, #1a1a1a);
border: var(--rozie-listbox-border-width, 1px) solid var(--rozie-listbox-popup-border, var(--rozie-listbox-border, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-listbox-popup-radius, var(--rozie-listbox-radius, 6px));
box-shadow: var(--rozie-listbox-shadow, 0 6px 24px rgba(0, 0, 0, 0.12));
}
.rozie-listbox-inline[data-rozie-s-b576227a] {
display: block;
width: 100%;
}
.rozie-listbox-inline[data-rozie-s-b576227a] .rozie-listbox-list[data-rozie-s-b576227a] {
position: static;
margin-top: var(--rozie-listbox-popup-offset, 4px);
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-listbox-option[data-rozie-s-b576227a] {
padding: var(--rozie-listbox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-listbox-option-radius, 4px);
color: var(--rozie-listbox-option-fg, inherit);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rozie-listbox-gap, 0.5rem);
}
.rozie-listbox-option.is-active[data-rozie-s-b576227a] {
background: var(--rozie-listbox-active-bg, rgba(0, 102, 204, 0.12));
color: var(--rozie-listbox-active-fg, inherit);
}
.rozie-listbox-option.is-selected[data-rozie-s-b576227a] {
background: var(--rozie-listbox-selected-bg, transparent);
color: var(--rozie-listbox-selected-fg, inherit);
font-weight: var(--rozie-listbox-selected-weight, 600);
}
.rozie-listbox-option.is-selected[data-rozie-s-b576227a]::after {
content: var(--rozie-listbox-check, '✓');
color: var(--rozie-listbox-check-color, var(--rozie-listbox-accent, #0066cc));
}
.rozie-listbox-option.is-disabled[data-rozie-s-b576227a] { opacity: var(--rozie-listbox-disabled-opacity, 0.45); cursor: not-allowed; }
.rozie-listbox-empty[data-rozie-s-b576227a] { padding: var(--rozie-listbox-option-padding, 0.5rem 0.6rem); color: var(--rozie-listbox-empty-fg, rgba(0, 0, 0, 0.5)); }
.rozie-listbox-spacer[data-rozie-s-b576227a] { margin: 0; padding: 0; border: 0; flex: none; }
`;
/**
* The option set. Each entry is either a primitive (`string`/`number`) or an object; objects resolve their label, value, and disabled state via the `option*` resolver props, falling back to `.label` / `.value` / `.disabled`.
*/
@property({ type: Array }) options: any[] = [];
/**
* The selected value (two-way `r-model`) — a scalar in single-select, an array of values in multi-select. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a Listbox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly).
* @example
* <Listbox r-model:value="fruit" :options="fruits" />
*/
@property({ type: Object, attribute: 'value' }) _value_attr: unknown = null;
private _valueControllable = createLitControllableProperty<unknown>({ host: this, eventName: 'value-change', defaultValue: null, initialControlledValue: undefined });
/**
* Enable multi-select: `value` becomes an array, selecting an option toggles its membership, and the popup stays open after each commit.
*/
@property({ type: Boolean, reflect: true }) multiple: boolean = false;
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the listbox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
@property({ type: Boolean, reflect: true }) inline: boolean = false;
/**
* Disable the control entirely. Also sets the Angular `ControlValueAccessor` disabled state.
*/
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
/**
* Placeholder text shown in the empty control.
*/
@property({ type: String, reflect: true }) placeholder: string = '';
/**
* Close the popup after a single-select commit. Defaults `true`; multi-select keeps the popup open regardless of this setting.
*/
@property({ type: Boolean, reflect: true }) closeOnSelect: boolean = true;
/**
* Resolver override for an object option's display label — `(option) => string`. Falls back to the option's `.label` property.
*/
@property({ type: Function }) optionLabel: ((...args: unknown[]) => unknown) | null = null;
/**
* Resolver override for an object option's committed value — `(option) => value`. Falls back to the option's `.value` property.
*/
@property({ type: Function }) optionValue: ((...args: unknown[]) => unknown) | null = null;
/**
* Resolver override marking an option non-selectable — `(option) => boolean`. Falls back to the option's `.disabled` property.
*/
@property({ type: Function }) optionDisabled: ((...args: unknown[]) => unknown) | null = null;
/**
* Stable id base for the ARIA wiring (the listbox id, per-option ids, and `aria-activedescendant`). Give each instance on a page a distinct id so these references stay unique.
*/
@property({ type: String, reflect: true }) id: string = 'rozie-listbox';
/**
* Accessible name for the control when there is no visible `<label for>` pointing at its `id` (`aria-label`).
*/
@property({ type: String, reflect: true }) ariaLabel: string | null = null;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling list (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed listbox. Pair with `inline` + `maxHeight` so the windowed scroll container is bounded.
*/
@property({ type: Boolean, reflect: true }) virtual: boolean = false;
/**
* Estimated option row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
@property({ type: Number, reflect: true }) estimateRowHeight: number = 36;
/**
* A CSS length string bounding the list scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-listbox-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
@property({ type: String, reflect: true }) maxHeight: string = '';
private _open$local = signal(false);
private _activeIndex = signal(-1);
private _query = signal('');
private _rows = signal<any[]>([]);
private _windowVer = signal(0);
private _editVer = signal(0);
@query('[data-rozie-ref="controlEl"]') private _refControlEl!: HTMLElement;
@query('[data-rozie-ref="triggerEl"]') private _refTriggerEl!: HTMLElement;
@query('[data-rozie-ref="listEl"]') private _refListEl!: HTMLElement;
@query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
private __rozieWatchInitial_0 = true;
@state() private _hasSlotSelected = false;
@queryAssignedElements({ slot: 'selected', flatten: true }) private _slotSelectedElements!: Element[];
@property({ attribute: false }) selected?: (scope: { selected: unknown; value: unknown }) => unknown;
@state() private _hasSlotOption = false;
@queryAssignedElements({ slot: 'option', flatten: true }) private _slotOptionElements!: Element[];
@property({ attribute: false }) option?: (scope: { option: unknown; index: unknown; active: unknown; selected: unknown; disabled: unknown }) => unknown;
@state() private _hasSlotEmpty = false;
@queryAssignedElements({ slot: 'empty', flatten: true }) private _slotEmptyElements!: Element[];
@property({ attribute: false }) empty?: (scope: { query: unknown }) => unknown;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
const _u0 = attachOutsideClickListener([() => this._refControlEl, () => this._refListEl], ($event) => { ((this.close) as (...args: any[]) => any)($event); }, () => (this._open$local.value));
this._disconnectCleanups.push(_u0);
{
const slotEl = this.shadowRoot?.querySelector('slot[name="selected"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotSelected = this._slotSelectedElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="option"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotOption = this._slotOptionElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="empty"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotEmpty = this._slotEmptyElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotSelected = Array.from(this.children).some((el) => el.getAttribute('slot') === 'selected');
this._hasSlotOption = Array.from(this.children).some((el) => el.getAttribute('slot') === 'option');
this._hasSlotEmpty = Array.from(this.children).some((el) => el.getAttribute('slot') === 'empty');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => (this.options ? this.options.length : 0) + '|' + this._query.value)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
this.syncRows();
if (this.virtual && this.virtualizer) {
this.gridScrollEl = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rozie-listbox-list') : this.gridScrollEl;
this.virtualizer.setOptions(this.virtualizerOptions());
this.virtualizer._willUpdate();
this._windowVer.value = this._windowVer.value + 1;
this.scheduleRemeasure();
}
})(); }); }));
this.syncRows();
if (this.virtual) {
// The list renders at mount when virtual, so the .rozie-listbox-list scroll container
// exists here. Capture it via $el.querySelector (the data-table gridScrollEl precedent,
// proven ×6 incl Lit shadow + Solid) — $refs on a conditionally-rendered node is null on
// Solid/Lit, which leaves the virtualizer with no scroll element.
this.gridScrollEl = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rozie-listbox-list') : null;
this.virtualizer = new Virtualizer(this.virtualizerOptions());
this.virtualizerCleanup = this.virtualizer._didMount();
this._windowVer.value = this._windowVer.value + 1;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => this.kickWindow(8));else setTimeout(() => this.kickWindow(8), 0);
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
() => {
if (this.typeTimer !== null) clearTimeout(this.typeTimer);
// Tear down the virtualizer's scroll-element ResizeObserver (no-op when virtual off).
if (this.virtualizerCleanup) this.virtualizerCleanup();
};
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'value') this._valueControllable.notifyAttributeChange(value as unknown as unknown);
}
render() {
return html`
<div class="${Object.entries({ "rozie-listbox": true, 'rozie-listbox-open': this._open$local.value, 'rozie-listbox-disabled': this.disabled, 'rozie-listbox-inline': this.inline }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="__rozieRoot" data-rozie-s-b576227a>
<div class="rozie-listbox-control" data-rozie-ref="controlEl" data-rozie-s-b576227a>
<button class="rozie-listbox-trigger" type="button" role="combobox" aria-haspopup="listbox" aria-expanded=${this._open$local.value} aria-controls=${rozieAttr(this.id + '-list')} aria-activedescendant=${rozieAttr(this.activeDescendant)} aria-label=${this.ariaLabel} ?disabled=${this.disabled} @click=${this.toggle} @keydown=${($event: Event) => { this.onControlKeyDown($event); }} data-rozie-ref="triggerEl" data-rozie-s-b576227a>
${this.selected !== undefined ? this.selected({selected: this.selectedLabel, value: this.value}) : html`<slot name="selected" data-rozie-params=${(() => { try { return JSON.stringify({selected: this.selectedLabel, value: this.value}); } catch { return '{}'; } })()}>
${this.selectedLabel ? html`<span class="rozie-listbox-selected" data-rozie-s-b576227a>${rozieDisplay(this.selectedLabel)}</span>` : html`<span class="rozie-listbox-placeholder" data-rozie-s-b576227a>${this.placeholder}</span>`}</slot>`}
<span class="rozie-listbox-arrow" aria-hidden="true" data-rozie-s-b576227a>▾</span>
</button>
</div>
${this._open$local.value && !this.virtual ? html`<div class="rozie-listbox-list" role="listbox" id=${rozieAttr(this.id + '-list')} aria-label=${this.ariaLabel} aria-multiselectable=${this.multiple} data-rozie-ref="listEl" data-rozie-s-b576227a>
${repeat<any>(this.visibleOptions(), (opt, index) => this.optionId(index), (opt, index) => html`<div class="${Object.entries({ "rozie-listbox-option": true, 'is-active': this._activeIndex.value === index, 'is-selected': this.isSelected(opt), 'is-disabled': this.disabledOf(opt) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieAttr(this.optionId(index))} id=${rozieAttr(this.optionId(index))} role="option" aria-selected=${!!this.isSelected(opt)} aria-disabled=${!!this.disabledOf(opt)} @click=${($event: Event) => { this.select(opt); }} @mousemove=${($event: Event) => { this.onOptionPointerMove(index); }} data-rozie-s-b576227a>
${this.option !== undefined ? this.option({option: opt, index: index, active: this._activeIndex.value === index, selected: this.isSelected(opt), disabled: this.disabledOf(opt)}) : html`<slot name="option" data-rozie-params=${(() => { try { return JSON.stringify({option: opt, index: index, active: this._activeIndex.value === index, selected: this.isSelected(opt), disabled: this.disabledOf(opt)}); } catch { return '{}'; } })()}>
${rozieDisplay(this.labelOf(opt))}
</slot>`}
</div>`)}
${this.visibleOptions().length === 0 ? html`<div class="rozie-listbox-empty" role="presentation" data-rozie-s-b576227a>
${this.empty !== undefined ? this.empty({query: this._query.value}) : html`<slot name="empty" data-rozie-params=${(() => { try { return JSON.stringify({query: this._query.value}); } catch { return '{}'; } })()}>No options</slot>`}
</div>` : nothing}</div>` : nothing}${this.virtual ? html`<div class="rozie-listbox-list rozie-listbox-list--virtual" role="listbox" id=${rozieAttr(this.id + '-list')} aria-label=${this.ariaLabel} aria-multiselectable=${this.multiple} style=${rozieStyle((this._open$local.value ? '' : 'display:none;') + (this.maxHeight ? 'height:' + this.maxHeight + ';max-height:' + this.maxHeight + ';overflow-y:auto;--rozie-listbox-max-height:' + this.maxHeight : 'overflow-y:auto'))} data-rozie-ref="listEl" data-rozie-s-b576227a>
<div class="rozie-listbox-spacer" aria-hidden="true" style=${rozieStyle('height:' + this.padTop() + 'px')} data-rozie-s-b576227a></div>
${repeat<any>(this.windowedRows(), (wr, _idx) => wr.row.id, (wr, _idx) => html`<div class="${Object.entries({ "rozie-listbox-option": true, 'is-active': this._activeIndex.value === wr.vi.index, 'is-selected': this.isSelected(wr.row._opt), 'is-disabled': this.disabledOf(wr.row._opt) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieAttr(wr.row.id)} id=${rozieAttr(this.optionId(wr.vi.index))} data-index=${rozieAttr(wr.vi.index)} role="option" aria-selected=${!!this.isSelected(wr.row._opt)} aria-disabled=${!!this.disabledOf(wr.row._opt)} @click=${($event: Event) => { this.select(wr.row._opt); }} @mousemove=${($event: Event) => { this.onOptionPointerMove(wr.vi.index); }} data-rozie-s-b576227a>
${this.option !== undefined ? this.option({option: wr.row._opt, index: wr.vi.index, active: this._activeIndex.value === wr.vi.index, selected: this.isSelected(wr.row._opt), disabled: this.disabledOf(wr.row._opt)}) : html`<slot name="option" data-rozie-params=${(() => { try { return JSON.stringify({option: wr.row._opt, index: wr.vi.index, active: this._activeIndex.value === wr.vi.index, selected: this.isSelected(wr.row._opt), disabled: this.disabledOf(wr.row._opt)}); } catch { return '{}'; } })()}>
${rozieDisplay(this.labelOf(wr.row._opt))}
</slot>`}
</div>`)}
<div class="rozie-listbox-spacer" aria-hidden="true" style=${rozieStyle('height:' + this.padBottom() + 'px')} data-rozie-s-b576227a></div>
${this.windowSource().length === 0 ? html`<div class="rozie-listbox-empty" role="presentation" data-rozie-s-b576227a>
${this.empty !== undefined ? this.empty({query: this._query.value}) : html`<slot name="empty" data-rozie-params=${(() => { try { return JSON.stringify({query: this._query.value}); } catch { return '{}'; } })()}>No options</slot>`}
</div>` : nothing}</div>` : nothing}</div>
`;
}
typeBuffer = '';
typeTimer: any = null;
labelOf = (opt: any) => {
if (this.optionLabel !== null) return this.optionLabel(opt);
if (opt !== null && typeof opt === 'object' && 'label' in opt) return opt.label;
return String(opt);
};
valueOf$local = (opt: any) => {
if (this.optionValue !== null) return this.optionValue(opt);
if (opt !== null && typeof opt === 'object' && 'value' in opt) return opt.value;
return opt;
};
disabledOf = (opt: any) => {
if (this.optionDisabled !== null) return !!this.optionDisabled(opt);
if (opt !== null && typeof opt === 'object' && 'disabled' in opt) return !!opt.disabled;
return false;
};
optionId = (index: any) => this.id + '-opt-' + index;
visibleOptions = () => {
const q = (this._query.value || '').trim().toLowerCase();
if (q === '') return this.options;
return this.options.filter((opt: any) => this.labelOf(opt).toLowerCase().includes(q));
};
get selectedLabel() {
const cur = this.value;
if (this.multiple) {
// Read the model value into a local before narrowing: `$props.value` lowers
// to a `value()` accessor on Solid, and Array.isArray() can't narrow two
// separate calls — narrowing one stable local works on every target.
const arr = Array.isArray(cur) ? cur : [];
if (arr.length === 0) return '';
return this.options.filter((o: any) => arr.includes(this.valueOf$local(o))).map(this.labelOf).join(', ');
}
const match = this.options.find((o: any) => this.valueOf$local(o) === cur);
return match === undefined ? '' : this.labelOf(match);
}
get activeDescendant() {
if (!this._open$local.value || this._activeIndex.value < 0) return null;
return this.optionId(this._activeIndex.value);
}
isSelected = (opt: any) => {
const v = this.valueOf$local(opt);
const cur = this.value;
if (this.multiple) return Array.isArray(cur) && cur.includes(v);
return cur === v;
};
resolveInitialActive = () => {
const opts = this.visibleOptions();
const sel = opts.findIndex((o: any) => this.isSelected(o) && !this.disabledOf(o));
if (sel !== -1) return sel;
return opts.findIndex((o: any) => !this.disabledOf(o));
};
applyExpanded = (next: any) => {
if (next && this.disabled) return;
if (this._open$local.value === next) return;
this._open$local.value = next;
this._activeIndex.value = next ? this.resolveInitialActive() : -1;
this.dispatchEvent(new CustomEvent("open-change", {
detail: {
open: next
},
bubbles: true,
composed: true
}));
};
open = () => this.applyExpanded(true);
close = () => this.applyExpanded(false);
toggle = () => this.applyExpanded(!this._open$local.value);
fireChange = (value: any, option: any) => this.dispatchEvent(new CustomEvent("change", {
detail: {
value,
option
},
bubbles: true,
composed: true
}));
select = (opt: any) => {
if (this.disabledOf(opt)) return;
const v = this.valueOf$local(opt);
if (this.multiple) {
const cur = this.value;
const arr = Array.isArray(cur) ? cur : [];
// Fresh array on every commit — in-place mutation is dropped by the
// React/Solid/Lit/Angular change detectors.
const next = arr.includes(v) ? arr.filter((x: any) => x !== v) : [...arr, v];
this._valueControllable.write(next);
this.fireChange(next, opt);
} else {
this._valueControllable.write(v);
this.fireChange(v, opt);
if (this.closeOnSelect) {
this.close();
this.focusControl();
}
}
};
clear = () => {
const empty = this.multiple ? [] : null;
this._valueControllable.write(empty);
this._query.value = '';
this.fireChange(empty, null);
};
nextEnabled = (from: any, dir: any) => {
const opts = this.visibleOptions();
if (opts.length === 0) return -1;
let i = from;
for (let step = 0; step < opts.length; step++) {
i += dir;
if (i < 0) i = opts.length - 1;else if (i >= opts.length) i = 0;
if (!this.disabledOf(opts[i])) return i;
}
return from;
};
move = (dir: any) => {
if (!this._open$local.value) {
this.open();
return;
}
const start = this._activeIndex.value < 0 ? dir > 0 ? -1 : 0 : this._activeIndex.value;
this._activeIndex.value = this.nextEnabled(start, dir);
this.scrollActiveIntoView();
};
moveEdge = (toEnd: any) => {
if (!this._open$local.value) this.open();
this._activeIndex.value = toEnd ? this.nextEnabled(-1, -1) : this.nextEnabled(-1, 1);
this.scrollActiveIntoView();
};
commitActive = () => {
const opts = this.visibleOptions();
if (this._activeIndex.value >= 0 && this._activeIndex.value < opts.length) this.select(opts[this._activeIndex.value]);
};
onTypeahead = (ch: any) => {
if (this.typeTimer !== null) clearTimeout(this.typeTimer);
this.typeBuffer += ch.toLowerCase();
this.typeTimer = setTimeout(() => {
this.typeBuffer = '';
}, 600);
const opts = this.visibleOptions();
const idx = opts.findIndex((o: any) => !this.disabledOf(o) && this.labelOf(o).toLowerCase().startsWith(this.typeBuffer));
if (idx !== -1) {
if (!this._open$local.value) this.open();
this._activeIndex.value = idx;
this.scrollActiveIntoView();
}
};
onControlKeyDown = ($event: any) => {
const key = $event.key;
if (key === 'ArrowDown') {
$event.preventDefault();
this.move(1);
} else if (key === 'ArrowUp') {
$event.preventDefault();
this.move(-1);
} else if (key === 'Home') {
$event.preventDefault();
this.moveEdge(false);
} else if (key === 'End') {
$event.preventDefault();
this.moveEdge(true);
} else if (key === 'Enter') {
if (this._open$local.value) {
$event.preventDefault();
this.commitActive();
}
} else if (key === 'Escape') {
if (this._open$local.value) {
$event.preventDefault();
this.close();
this.focusControl();
}
} else if (key === ' ' || key === 'Spacebar') {
// Space toggles / commits in a select-only host (a button trigger). A
// filter-input host types the literal space into its <input> and does NOT
// route Space through this reducer, so this branch is select-only by use.
$event.preventDefault();
if (!this._open$local.value) this.open();else this.commitActive();
} else if (key === 'Tab') {
if (this._open$local.value) this.close();
} else if (key.length === 1 && !$event.metaKey && !$event.ctrlKey && !$event.altKey) {
this.onTypeahead(key);
}
};
onOptionPointerMove = (index: any) => {
if (this._activeIndex.value !== index) this._activeIndex.value = index;
};
virtualItemKey = (i: any) => {
const src = this.windowSource();
return src && src[i] ? src[i].id : undefined;
};
virtualizerOptions = (): any => ({
count: this.windowSource().length,
getScrollElement: () => this.gridScrollEl,
estimateSize: () => this.estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: this.virtualItemKey,
onChange: () => {
this._windowVer.value = this._windowVer.value + 1;
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
this.scheduleRemeasure();
}
});
pinMeasurement = (pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null => this.pinnedMeasurement(pin);
windowedRows = () => {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void this._windowVer.value;
void this._editVer.value;
if (!this.virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!this.virtual) {
const rowList = this._rows.value || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = this.virtualizer.getVirtualItems();
const rowList = this._rows.value || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = this.pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = this.pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
};
padTop = () => {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void this._windowVer.value;
void this._editVer.value;
if (!this.virtual || !this.virtualizer) return 0;
const items = this.virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = this.pinnedEditIndex();
if (pin >= 0) {
const pm = this.pinMeasurement(pin);
const inWindow = this.pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
};
padBottom = () => {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void this._windowVer.value;
void this._editVer.value;
if (!this.virtual || !this.virtualizer) return 0;
const items = this.virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = this.virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = this.pinnedEditIndex();
if (pin >= 0) {
const pm = this.pinMeasurement(pin);
const inWindow = this.pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
};
pmIndexInWindow = (items: any, idx: any) => {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
};
rowIsOutsideWindow = (r: any) => {
if (!this.virtual || !this.virtualizer) return false;
const items = this.virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
virtualizer: any = null;
virtualizerCleanup: any = null;
gridScrollEl: any = null;
remeasurePending = false;
windowSource = () => this.visibleOptions().map((o: any, i: any) => ({
id: this.valueOf$local(o),
_opt: o,
_i: i
}));
pinnedEditIndex = () => -1;
pinnedMeasurement = (pin: any) => null;
syncRows = () => {
this._rows.value = this.windowSource();
};
scheduleRemeasure = () => {
if (this.remeasurePending) return;
this.remeasurePending = true;
let ranMicro = false;
const microPass = () => {
this.remeasureWindow();
};
const rafPass = () => {
this.remeasurePending = false;
this.remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
};
remeasureWindow = () => {
if (!this.virtualizer || !this.gridScrollEl) return;
if (this.virtualizer.scrollState) return;
const els = this.gridScrollEl.querySelectorAll('.rozie-listbox-option[data-index]');
for (const el of els as any) this.virtualizer.measureElement(el);
};
focusControl = () => {
this._refTriggerEl?.focus();
};
scrollActiveIntoView = () => {
if (this._activeIndex.value < 0) return;
if (this.virtual && this.virtualizer) {
// 'center' (not 'auto'): keep the active option well inside the rendered slice as the
// window scrolls — 'auto' lands it at the viewport edge where the overscan band can
// leave it just-unrendered for a frame on the fine-grained targets (Solid).
this.virtualizer.scrollToIndex(this._activeIndex.value, {
align: 'center'
});
this.scheduleRemeasure();
return;
}
if (!this._refListEl) return;
const el = this._refListEl.querySelector('#' + CSS.escape(this.optionId(this._activeIndex.value)));
el?.scrollIntoView({
block: 'nearest'
});
};
kickWindow = (attempts: any) => {
if (!this.virtualizer) return;
this.gridScrollEl = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rozie-listbox-list') : this.gridScrollEl;
// Only re-feed the count from a NON-EMPTY source: on React these rAF closures capture
// stale (mount-time, empty) props, so feeding here would CLOBBER the $watch's correct
// count back to 0. The $watch (fresh useEffect props) owns React's count; the kick owns
// the Solid/Lit scroll-element re-attach + the deferred windowVer re-derive.
if (this.windowSource().length > 0) {
this.syncRows();
this.virtualizer.setOptions(this.virtualizerOptions());
}
this.virtualizer._willUpdate();
this._windowVer.value = this._windowVer.value + 1;
this.remeasureWindow();
if (this.windowedRows().length === 0 && attempts > 0) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => this.kickWindow(attempts - 1));else setTimeout(() => this.kickWindow(attempts - 1), 16);
}
};
get value(): unknown { return this._valueControllable.read(); }
set value(v: unknown) { this._valueControllable.notifyPropertyWrite(v); }
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['options', 'value', 'multiple', 'inline', 'disabled', 'placeholder', 'close-on-select', 'closeonselect', 'option-label', 'optionlabel', 'option-value', 'optionvalue', 'option-disabled', 'optiondisabled', 'id', 'aria-label', 'arialabel', 'virtual', 'estimate-row-height', 'estimaterowheight', 'max-height', 'maxheight']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component (with ControlValueAccessor), a Solid component, and a Lit custom element. Same props, same events, same two-way value, same scoped slots, same imperative handle — all from the one source above, with no third-party engine behind it.
See also
- Listbox — showcase & API — install, quick start, theming, keyboard, and the full reference.
- Headless select / combobox comparison — how
@rozie-ui/listboxstacks up against Headless UI, Radix, React Aria, Melt, Kobalte, and the CDK.