Appearance
Combobox — live demo
This is the real @rozie-ui/combobox-vue package running on this page (VitePress is itself a Vue app). Type to filter, use the arrow keys to move the highlight, press Enter to pick, or click an option — then watch the two-way bound value update. Everything below is driven by the same Combobox.rozie source that compiles to all six frameworks, built on native DOM with no engine and no required CSS — the WAI-ARIA behaviour and a tokenised skin all 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 Framework picker's buttons drive the imperative handle (clear(), focus()) grabbed through Vue's ref. The Country picker supplies a custom #option template (the scoped slot exposes { option, active, selected }) and listens to @search to surface the typed query — the same hook you would use for async / server-side filtering (set disableFilter and refetch options from the query). See the full API for every prop, event, slot, and handle verb, plus filtering, theming, keyboard, and accessibility reference.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
Combobox.rozie — a headless, WAI-ARIA accessible combobox / autocomplete.
A pure-Rozie family (NO third-party engine). The hardest of the no-engine
primitives to get right cross-framework — the WAI-ARIA combobox pattern (a text
input + a popup listbox, aria-activedescendant keyboard navigation) is
re-implemented (often inaccessibly) in every framework. Rozie owns the
author-side API: the two-way `value` binding, the internal query + open +
active-descendant state, client/async filtering, the keyboard model, and the
token-themed skin.
SINGLE-SELECT, single model: `value` is the selected option's value (the sole
model:true prop → Angular ControlValueAccessor; a combobox IS a form control).
The input TEXT is internal `$data.query` (NOT a second model — two models would
forfeit the CVA, ROZ125); a `search` event exposes it for async / server-side
filtering (pair with `disableFilter`).
v1 SCOPE (documented): the popup is positioned directly below the input (no
floating-ui auto-flip/shift — a deliberate no-engine v1 limitation). Dismissal
uses the robust headless pattern — options fire `@mousedown.prevent` (selection
happens BEFORE the input loses focus, and focus is kept) and the input's `@blur`
closes the popup — so there is NO document click-outside listener and therefore
no cross-Lit-shadow retargeting problem.
Authoring notes (collision classes — see the authoring playbook):
- The id-base prop is `idBase`, NOT `id`: a prop literally named `id` shadows
the inherited HTMLElement.id on the Lit custom element (the inherited-DOM-
member class — cf. otp's inputMode→cellInputMode). Option element ids are
derived `idBase + '-opt-' + i` for aria-activedescendant.
- The focus verb is `focus` (accepted ROZ137 Lit override, documented in every
leaf README — the slider/otp precedent). `clear` is collision-safe.
- `value` is the model → Angular generates a ControlValueAccessor.writeValue;
no helper here is named writeValue/registerOnChange/etc. (would be TS2300).
- Handler params left UNTYPED (neutralize to `any`). `filteredOptions()` is a
PLAIN function (called from the r-for AND handlers) — never $computed.
Consumer example:
<Combobox r-model:value="$data.country" :options="countries"
placeholder="Search…" ariaLabel="Country" @change="onPick" />
-->
<rozie name="Combobox" vendorable="true">
<props>
{
// The selected option's value (two-way). Sole model:true prop → Angular CVA.
value: {
type: null,
default: null,
model: true,
docs: {
description:
"The selected option's value (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a combobox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `null` when nothing is selected.",
example: '<Combobox r-model:value="country" :options="countries" />',
},
},
// The option list: `[{ value, label, disabled? }]`.
options: {
type: Array,
default: () => [],
docs: {
description:
'The option list — `[{ value, label, disabled? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, and an optional `disabled` flag makes an option non-selectable.',
},
},
placeholder: {
type: String,
default: '',
docs: {
description: 'Placeholder text shown in the input while it is empty.',
},
},
disabled: {
type: Boolean,
default: false,
docs: {
description:
'Disable the control — the input becomes non-interactive and the popup cannot be opened. Also sets the Angular `ControlValueAccessor` disabled state.',
},
},
// Opt OUT of built-in client filtering (async / server-side mode): show the
// `options` as supplied and rely on the `search` event to refetch. Default:
// filter `options` by label against the typed query.
disableFilter: {
type: Boolean,
default: false,
docs: {
description:
'Opt **out** of built-in client filtering (async / server-side mode): render `options` exactly as supplied and rely on the `search` event to refetch. By default the component filters `options` by `label`, case-insensitively, against the typed query.',
},
},
// Accessible name for the input (when there is no visible <label for>).
ariaLabel: {
type: String,
default: null,
docs: {
description:
'Accessible name for the input (`aria-label`), used when there is no visible `<label for>` pointing at it. Provide this (or an external label) so the combobox is announced.',
},
},
// id base for the listbox + option elements (aria-activedescendant needs real
// ids). Set a DISTINCT value per instance when more than one combobox is on a
// page. Named `idBase` (not `id`) to avoid shadowing HTMLElement.id on Lit.
idBase: {
type: String,
default: 'rozie-combobox',
docs: {
description:
'Id base for the listbox and option elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`. Set a **distinct** value per instance when more than one combobox shares a page. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.',
},
},
// Render the results list IN FLOW (static) instead of as an absolute popup. Use
// when the combobox 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. (Grown in P3 to
// absorb command-palette — the Listbox `inline` prop it is copied from.)
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 combobox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).',
},
},
// Close the popup after a selection. DEFAULT TRUE — a justified, intentional
// deviation from the project rule `feedback_boolean_props_default_false`
// (negative opt-out / default false), taken here for cross-family API parity
// with the `@rozie-ui/listbox` sibling's `closeOnSelect` prop this is copied
// from (command-palette + existing consumers rely on the default-true close).
closeOnSelect: {
type: Boolean,
default: true,
docs: {
description:
'Close the popup after a selection commits. Defaults `true` (standard autocomplete behavior); set to `false` to keep the popup open after a selection — e.g. when the combobox is embedded in a multi-action surface like a command palette.',
},
},
// Resolver overrides for object options. Fall back to `.label` / `.value` /
// `.disabled`. (Grown in P3 + lifted to the shared listCore spine so callers
// can drive non-`{label,value}`-shaped option objects, e.g. command items.)
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.",
},
},
// ── 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 popup (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 combobox.
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 popup (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed combobox. 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 popup scroll container when `virtual` is on
// (e.g. '320px'). Mirrored to the --rozie-combobox-list-max-height token.
maxHeight: {
type: String,
default: '',
docs: {
description:
"A CSS length string bounding the popup scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-combobox-list-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.",
},
},
}
</props>
<data>
{
// The text in the input.
query: '',
// Popup visibility.
isOpen: false,
// Highlighted option index (within the FILTERED list) for aria-activedescendant.
activeIndex: -1,
// ── 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() / filteredOptions()); windowVer/editVer are the
// window/edit version reactivity bumps (editVer is inert — 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 lang="ts">
// ---- shared list spine (P3: @rozie-ui/headless-core/listCore.rzts) ------
// The option resolvers (label/value/disabled, with the optionLabel/optionValue/
// optionDisabled prop overrides) now live in the shared, focus-/input-mode
// parameterized list spine that Listbox also consumes (D-06). It is a
// compile-time `.rzts` script-partial: only the imported symbols are inlined and
// they DISSOLVE into this leaf via inlineScriptPartials() before IR lowering
// (zero runtime dep). Combobox consumes it in focus-model `activedescendant` +
// input-mode `filter-input`, single-select. The open/active/query state machine
// + the combobox <input> wiring stay host-local (Combobox's `$data.isOpen` /
// `idBase` / query-on-select shape differs from Listbox's `$data.open` / `id`).
import { labelOf, valueOf, disabledOf } 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 (in THIS host, not the shared
// partial, so data-table's A==B byte-identity is untouched). The impure DOM/refs pieces
// stay HERE per-consumer (ROZ123).
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;
// 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 (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
let virtualizer = null
let virtualizerCleanup = null
let gridScrollEl = null
let remeasurePending = false
// ---- derived view (plain functions, uniform ×6) ------------------------
// The filtered option list, each carrying its filtered-list index `_i`, a stable
// windowing key `id`, and the RAW source option (`option`) so `@change` + the
// `#option` slot expose the original object (CP reads `e.option.id` / `option.group`).
//
// REFERENCE-KEYED MEMO, NOT $computed — this is load-bearing for windowed perf. TanStack
// virtual-core calls getItemKey(i)/getMeasurements O(count) times per pass, and windowSource()
// (below) aliases this, so without a memo every scroll re-`.map()`s ALL options into fresh
// wrapper objects — O(N²). On vue each wrapper read trips a reactive Proxy trap (valueOf/labelOf/
// disabledOf), so a 60-ArrowDown batch over 1,000 options cost ~16s. It is deliberately NOT a
// $computed: a $computed would re-SUBSCRIBE to the reactive `options` Proxy and re-run on
// unrelated reactive churn (and on vue re-trip the Proxy traps); the whole point is to AVOID
// re-mapping when only activeIndex changed. The cache key is pure VALUE/REFERENCE comparison
// (no reactive subscription), so it adds zero reactivity churn — it collapses virtual-core's
// O(count) re-maps to ONE map per real (options-ref / query / disableFilter) change.
//
// foCache is a member-mutated FRESH-OBJECT const (NOT a reassigned `let`): the React emitter
// lowers `const X = {…}` that is member-mutated to `useMemo(() => ({…}), [])` (per-instance,
// stable across renders — feedback_react_const_mutinstance_not_stabilized); on the 5 setup-once
// targets the top-level const persists for the instance lifetime naturally. A reassigned
// `let X = null` would NOT survive React renders (filteredOptions() is reached from the TEMPLATE,
// not a hook-root → per-render reset trap), so it MUST be a fresh-object const.
const foCache = { optsRef: null, q: null, df: null, val: null, hasVal: false }
const filteredOptions = () => {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
// stale/blank window.
const opts = Array.isArray($props.options) ? $props.options : []
const df = !!$props.disableFilter
const q = String($data.query == null ? '' : $data.query)
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
if (foCache.hasVal && foCache.optsRef === opts && foCache.q === q && foCache.df === df) return foCache.val
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
let list = opts
if (!df) {
const ql = q.toLowerCase()
if (ql) list = opts.filter((o) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1)
}
const val = list.map((o, i) => ({ value: valueOf(o), label: labelOf(o), disabled: disabledOf(o), _i: i, id: valueOf(o), option: o }))
foCache.optsRef = opts
foCache.q = q
foCache.df = df
foCache.val = val
foCache.hasVal = true
return val
}
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// list (the same wrapper rows the template iterates). Kept === $data.rows so the math's
// rowList[vi.index] resolves to the same wrapper the count windows over.
const windowSource = () => filteredOptions()
// 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 combobox 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 filtered set.
const syncRows = () => { $data.rows = windowSource() }
// Defer remeasureWindow() until AFTER the framework commits the recycled window: TWO
// passes (microtask THEN rAF) behind one in-flight flag (the data-table
// virtualization.rzts pattern, copied per-consumer per D-04/D-09) — microtask catches
// Solid's <For> / Svelte's {#each} synchronous commit (the Phase 63 Solid
// under-convergence hazard — D-09 rAF-defer budget), rAF catches React's async commit.
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 height is observed (virtual-core measures ONLY nodes passed to measureElement,
// keyed by the data-index attribute). Bails during a programmatic scroll.
const remeasureWindow = () => {
if (!virtualizer || !gridScrollEl) return
if (virtualizer.scrollState) return
const els = gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]')
for (const el of els) virtualizer.measureElement(el)
}
// Keep the active option visible inside the windowed popup. When windowing, route
// through the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered
// window scrolls into view (the windowed-arrow-nav seam). No-op when not virtual (the
// non-virtual combobox popup is short enough not to need it — unchanged behavior).
const scrollActiveIntoView = () => {
if (!$props.virtual || !virtualizer || $data.activeIndex < 0) return
// 'center' (not 'auto'): keep the active option well inside the rendered slice — '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()
}
const optId = (i) => $props.idBase + '-opt-' + i
const listId = () => $props.idBase + '-list'
// The active option's id for aria-activedescendant (null when none).
const activeId = () => {
const list = filteredOptions()
if ($data.isOpen && $data.activeIndex >= 0 && list[$data.activeIndex]) return optId($data.activeIndex)
return null
}
// Next selectable index in `dir` (+1/-1), skipping disabled, clamped to ends.
const nextEnabled = (list, from, dir) => {
let i = from
for (let step = 0; step < list.length; step++) {
i = i + dir
if (i < 0) i = 0
if (i >= list.length) i = list.length - 1
if (list[i] && !list[i].disabled) return i
if ((dir < 0 && i === 0) || (dir > 0 && i === list.length - 1)) break
}
return from
}
// ---- selection (writes the model + syncs query) ------------------------
// `opt` is a filtered-row wrapper ({ value, label, disabled, _i, option }). Fire
// `@change` with BOTH the committed value AND the raw source `option` (CP reads
// `e.option`). `closeOnSelect` (default true) gates the popup close — a caller
// embedding the combobox in a multi-action surface passes `:close-on-select="false"`.
const selectOption = (opt) => {
if (!opt || opt.disabled) return
$model.value = opt.value
$data.query = String(opt.label)
if ($props.closeOnSelect) $data.isOpen = false
$data.activeIndex = -1
$emit('change', { value: opt.value, option: opt.option })
}
// Reflect the externally-selected value into the input text.
const syncQueryToValue = () => {
const opts = Array.isArray($props.options) ? $props.options : []
const opt = opts.find((o) => o.value === $props.value)
$data.query = opt ? String(opt.label) : ''
}
// ---- input + keyboard handlers -----------------------------------------
const onInput = (e) => {
const q = e && e.target ? e.target.value : ''
$data.query = q
$data.isOpen = true
$data.activeIndex = 0
$emit('search', { query: q })
}
const onFocus = (e) => {
$data.isOpen = true
if (e && e.target && e.target.select) e.target.select()
}
// @blur closes the popup. Option selection uses @mousedown.prevent, which keeps
// focus on the input, so a click on an option does NOT blur-close before select.
const onBlur = () => {
$data.isOpen = false
}
const onKeydown = (e) => {
const key = e ? e.key : ''
const list = filteredOptions()
// Capture the reactive reads into locals BEFORE any write so React never binds
// a pre-write value (ROZ138; the read-then-write-same-key idiom). Each branch
// is mutually exclusive, but a flow-insensitive analysis can't see that.
const wasOpen = $data.isOpen
const ai = $data.activeIndex
if (key === 'ArrowDown') {
if (e) e.preventDefault()
if (!wasOpen) {
$data.isOpen = true
$data.activeIndex = 0
return
}
$data.activeIndex = nextEnabled(list, ai, 1)
} else if (key === 'ArrowUp') {
if (e) e.preventDefault()
if (!wasOpen) {
$data.isOpen = true
return
}
$data.activeIndex = nextEnabled(list, ai, -1)
} else if (key === 'Enter') {
if (wasOpen && ai >= 0 && list[ai]) {
if (e) e.preventDefault()
selectOption(list[ai])
}
} else if (key === 'Escape') {
if (wasOpen) {
if (e) e.preventDefault()
$data.isOpen = false
}
} else if (key === 'Home') {
if (wasOpen) {
if (e) e.preventDefault()
$data.activeIndex = nextEnabled(list, -1, 1)
}
} else if (key === 'End') {
if (wasOpen) {
if (e) e.preventDefault()
$data.activeIndex = nextEnabled(list, list.length, -1)
}
}
// Keep the (new) active option in view when windowing — no-op when not virtual.
scrollActiveIntoView()
}
// ---- lifecycle + imperative handle -------------------------------------
// kickWindow: the cross-target first-paint settle (the data-table / listbox precedent).
// Re-captures the LIVE scroll element, re-feeds the CURRENT option count, re-attaches the
// rect observer (_willUpdate), and bumps the windowVer signal so the windowed slice
// 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 (stale scrollElement),
// and (c) the consumer often seeds options AFTER the combobox mounts (Lit/React). Stops once
// the window paints — idempotent + loop-free.
const kickWindow = (attempts) => {
if (!virtualizer) return
gridScrollEl = $el ? $el.querySelector('.rozie-combobox-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(() => {
syncQueryToValue()
syncRows()
// ── Windowing: construct the virtualizer (ONLY when virtual) ──────────────
// The windowed popup stays mounted whenever virtual (r-if="$props.virtual"); it is only
// hidden via display:none when closed (CR-01), so the .rozie-combobox-list scroll
// container already exists here for the virtualizer to attach to.
if ($props.virtual) {
// Capture the scroll container 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, leaving the virtualizer with no scroll element.
gridScrollEl = $el ? $el.querySelector('.rozie-combobox-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)
}
})
// Lazy watch: reflect external value resets into the input text (does NOT fire on
// the user's own typing, which changes query but not value).
$watch(() => $props.value, () => {
syncQueryToValue()
})
// 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) {
virtualizer.setOptions(virtualizerOptions())
virtualizer._willUpdate()
$data.windowVer = $data.windowVer + 1
scheduleRemeasure()
}
})
// Tear down the virtualizer's scroll-element ResizeObserver on unmount (no-op when
// virtual was off — cleanup stays null).
$onUnmount(() => {
if (virtualizerCleanup) virtualizerCleanup()
})
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. Both post-mount → $refs safe.
const focus = () => $refs.inputEl?.focus()
const clear = () => {
$model.value = null
$data.query = ''
$data.activeIndex = -1
$emit('change', { value: null })
}
$expose({ focus, clear })
</script>
<template>
<div
class="rozie-combobox"
:class="{ 'rozie-combobox--open': $data.isOpen, 'rozie-combobox--disabled': $props.disabled, 'rozie-combobox--inline': $props.inline }"
>
<input
ref="inputEl"
class="rozie-combobox-input"
type="text"
role="combobox"
aria-autocomplete="list"
:aria-expanded="!!$data.isOpen"
:aria-controls="listId()"
:aria-activedescendant="activeId()"
:aria-label="$props.ariaLabel"
:value="$data.query"
:placeholder="$props.placeholder"
:disabled="!!$props.disabled"
autocomplete="off"
@input="onInput($event)"
@focus="onFocus($event)"
@blur="onBlur()"
@keydown="onKeydown($event)"
/>
<!-- NON-VIRTUAL popup. Byte-identical to the pre-windowing render — the
`&& !$props.virtual` only routes virtual usage to the windowed branch below. -->
<ul
r-if="$data.isOpen && !$props.virtual"
class="rozie-combobox-list"
:id="listId()"
role="listbox"
>
<li
r-for="opt in filteredOptions()"
:key="opt.value"
class="rozie-combobox-option"
:class="{ 'rozie-combobox-option--active': opt._i === $data.activeIndex, 'rozie-combobox-option--selected': opt.value === $props.value, 'rozie-combobox-option--disabled': opt.disabled }"
:id="optId(opt._i)"
role="option"
:aria-selected="opt.value === $props.value"
:aria-disabled="!!opt.disabled"
@mousedown.prevent="selectOption(opt)"
@mouseenter="$data.activeIndex = opt._i"
>
<slot name="option" :option="opt.option" :index="opt._i" :active="opt._i === $data.activeIndex" :selected="opt.value === $props.value" :disabled="opt.disabled">{{ opt.label }}</slot>
</li>
<li r-if="filteredOptions().length === 0" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="$data.query">No results</slot>
</li>
</ul>
<!-- ══ WINDOWED popup (Phase 64 P4, SC-5) — emitted/active ONLY when $props.virtual ══
Stays MOUNTED whenever virtual (so the .rozie-combobox-list scroll container exists
at mount for the virtualizer — ROZ123-safe) but is HIDDEN via display:none whenever
the combobox 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 onBlur()/Escape close semantics (the non-virtual branch is gated on
$data.isOpen; 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 input stays consistent. Renders a leading spacer, the windowed { vi, row }
slice keyed on the full-model wrapper.id, and a trailing spacer; the option's
full-model (filtered) index is wr.vi.index. The container is bounded/scrolling via
the base .rozie-combobox-list CSS (max-height from the
--rozie-combobox-list-max-height token, mirrored from the maxHeight prop). -->
<ul
r-if="$props.virtual"
class="rozie-combobox-list rozie-combobox-list--virtual"
:id="listId()"
role="listbox"
:style="($data.isOpen ? '' : 'display:none;') + ($props.maxHeight ? ('height:' + $props.maxHeight + ';max-height:' + $props.maxHeight + ';overflow-y:auto;--rozie-combobox-list-max-height:' + $props.maxHeight) : 'overflow-y:auto')"
>
<li class="rozie-combobox-spacer" aria-hidden="true" :style="'height:' + padTop() + 'px'"></li>
<li
r-for="wr in windowedRows()"
:key="wr.row.id"
class="rozie-combobox-option"
:class="{ 'rozie-combobox-option--active': wr.vi.index === $data.activeIndex, 'rozie-combobox-option--selected': wr.row.value === $props.value, 'rozie-combobox-option--disabled': wr.row.disabled }"
:id="optId(wr.vi.index)"
:data-index="wr.vi.index"
role="option"
:aria-selected="wr.row.value === $props.value"
:aria-disabled="!!wr.row.disabled"
@mousedown.prevent="selectOption(wr.row)"
@mouseenter="$data.activeIndex = wr.vi.index"
>
<slot name="option" :option="wr.row.option" :index="wr.vi.index" :active="wr.vi.index === $data.activeIndex" :selected="wr.row.value === $props.value" :disabled="wr.row.disabled">{{ wr.row.label }}</slot>
</li>
<li class="rozie-combobox-spacer" aria-hidden="true" :style="'height:' + padBottom() + 'px'"></li>
<li r-if="windowSource().length === 0" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="$data.query">No results</slot>
</li>
</ul>
</div>
</template>
<style>
/*
Token-driven (mirrors slider/otp themes): every visual value is a
`var(--rozie-combobox-*, <fallback>)`. The shipped themes/*.css presets map
these onto shadcn/Radix, Material 3, Bootstrap 5.
*/
.rozie-combobox {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, 16rem);
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input {
box-sizing: border-box;
width: 100%;
padding: var(--rozie-combobox-input-padding, 0.5rem 0.75rem);
font: inherit;
color: var(--rozie-combobox-color, inherit);
background: var(--rozie-combobox-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-border-color, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-combobox-radius, 0.5rem);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input:focus {
border-color: var(--rozie-combobox-accent, #0066cc);
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, 3px) var(--rozie-combobox-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-combobox--disabled .rozie-combobox-input {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, 0.55);
background: var(--rozie-combobox-disabled-bg, rgba(0, 0, 0, 0.04));
}
.rozie-combobox-list {
position: absolute;
z-index: var(--rozie-combobox-list-z, 50);
top: calc(100% + var(--rozie-combobox-list-gap, 0.25rem));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-combobox-list-padding, 0.25rem);
list-style: none;
max-height: var(--rozie-combobox-list-max-height, 16rem);
overflow-y: auto;
background: var(--rozie-combobox-list-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-list-border-color, rgba(0, 0, 0, 0.15));
border-radius: var(--rozie-combobox-radius, 0.5rem);
box-shadow: var(--rozie-combobox-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
}
.rozie-combobox-option {
padding: var(--rozie-combobox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-combobox-option-radius, 0.375rem);
cursor: pointer;
color: var(--rozie-combobox-option-color, inherit);
}
.rozie-combobox-option--active {
background: var(--rozie-combobox-option-active-bg, rgba(0, 102, 204, 0.12));
}
.rozie-combobox-option--selected {
font-weight: var(--rozie-combobox-option-selected-weight, 600);
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, #0066cc));
}
.rozie-combobox-option--disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, 0.45);
}
.rozie-combobox-empty {
padding: var(--rozie-combobox-empty-padding, 0.5rem 0.6rem);
color: var(--rozie-combobox-empty-color, rgba(0, 0, 0, 0.5));
list-style: none;
}
/* Windowing spacer rows (Phase 64 P4): zero-chrome <li> whose inline height keeps the
total scroll height === virtual-core getTotalSize() (the windowed slice sits between). */
.rozie-combobox-spacer { margin: 0; padding: 0; border: 0; list-style: none; }
/* Inline mode: the root fills its container and the list renders IN FLOW so an
overflow-clipped ancestor (e.g. a command-palette panel) can't cull it. Mirrors
the listbox `inline` prop this is copied from (P3 — command-palette absorb). */
.rozie-combobox--inline {
display: block;
width: 100%;
}
.rozie-combobox--inline .rozie-combobox-list {
position: static;
margin-top: var(--rozie-combobox-list-gap, 0.25rem);
border: none;
border-radius: 0;
box-shadow: 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/combobox-{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 } from '@rozie/runtime-react';
import './Combobox.css';
// virtual-core: the framework-agnostic windowing state machine (the data-table
// precedent — NO per-framework adapter). The static import is emitted unconditionally;
// 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 (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
interface OptionCtx { option: any; index: any; active: any; selected: any; disabled: any; }
interface EmptyCtx { query: any; }
interface ComboboxProps {
/**
* The selected option's value (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a combobox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `null` when nothing is selected.
* @example
* <Combobox r-model:value="country" :options="countries" />
*/
value?: (unknown) | null;
defaultValue?: (unknown) | null;
onValueChange?: (value: (unknown) | null) => void;
/**
* The option list — `[{ value, label, disabled? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, and an optional `disabled` flag makes an option non-selectable.
*/
options?: any[];
/**
* Placeholder text shown in the input while it is empty.
*/
placeholder?: string;
/**
* Disable the control — the input becomes non-interactive and the popup cannot be opened. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Opt **out** of built-in client filtering (async / server-side mode): render `options` exactly as supplied and rely on the `search` event to refetch. By default the component filters `options` by `label`, case-insensitively, against the typed query.
*/
disableFilter?: boolean;
/**
* Accessible name for the input (`aria-label`), used when there is no visible `<label for>` pointing at it. Provide this (or an external label) so the combobox is announced.
*/
ariaLabel?: (string) | null;
/**
* Id base for the listbox and option elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`. Set a **distinct** value per instance when more than one combobox shares a page. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase?: string;
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the combobox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
inline?: boolean;
/**
* Close the popup after a selection commits. Defaults `true` (standard autocomplete behavior); set to `false` to keep the popup open after a selection — e.g. when the combobox is embedded in a multi-action surface like a command palette.
*/
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;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling popup (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed combobox. 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 popup scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-combobox-list-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight?: string;
onChange?: (...args: any[]) => void;
onSearch?: (...args: any[]) => void;
renderOption?: (ctx: OptionCtx) => ReactNode;
renderEmpty?: (ctx: EmptyCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface ComboboxHandle {
focus: (...args: any[]) => any;
clear: (...args: any[]) => any;
}
const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox(_props: ComboboxProps, ref): JSX.Element {
const __defaultOptions = useState(() => (() => [])())[0];
const props: Omit<ComboboxProps, 'options' | 'placeholder' | 'disabled' | 'disableFilter' | 'ariaLabel' | 'idBase' | 'inline' | 'closeOnSelect' | 'optionLabel' | 'optionValue' | 'optionDisabled' | 'virtual' | 'estimateRowHeight' | 'maxHeight'> & { options: any[]; placeholder: string; disabled: boolean; disableFilter: boolean; ariaLabel: (string) | null; idBase: string; inline: boolean; closeOnSelect: boolean; optionLabel: ((...args: any[]) => any) | null; optionValue: ((...args: any[]) => any) | null; optionDisabled: ((...args: any[]) => any) | null; virtual: boolean; estimateRowHeight: number; maxHeight: string } = {
..._props,
options: _props.options ?? __defaultOptions,
placeholder: _props.placeholder ?? '',
disabled: _props.disabled ?? false,
disableFilter: _props.disableFilter ?? false,
ariaLabel: _props.ariaLabel ?? null,
idBase: _props.idBase ?? 'rozie-combobox',
inline: _props.inline ?? false,
closeOnSelect: _props.closeOnSelect ?? true,
optionLabel: _props.optionLabel ?? null,
optionValue: _props.optionValue ?? null,
optionDisabled: _props.optionDisabled ?? null,
virtual: _props.virtual ?? false,
estimateRowHeight: _props.estimateRowHeight ?? 36,
maxHeight: _props.maxHeight ?? '',
};
const attrs: Record<string, unknown> = (() => {
const { value, options, placeholder, disabled, disableFilter, ariaLabel, idBase, inline, closeOnSelect, optionLabel, optionValue, optionDisabled, virtual, estimateRowHeight, maxHeight, defaultValue, onValueChange, ...rest } = _props as ComboboxProps & Record<string, unknown>;
void value; void options; void placeholder; void disabled; void disableFilter; void ariaLabel; void idBase; void inline; void closeOnSelect; void optionLabel; void optionValue; void optionDisabled; 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 [value, setValue] = useControllableState({
value: props.value,
defaultValue: props.defaultValue ?? null,
onValueChange: props.onValueChange,
});
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [rows, setRows] = useState<any[]>([]);
const [windowVer, setWindowVer] = useState(0);
const [editVer, setEditVer] = useState(0);
const inputEl = useRef<HTMLInputElement | null>(null);
const __rozieRoot = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
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 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;
}
const foCache = useMemo(() => ({
optsRef: null,
q: null,
df: null,
val: null,
hasVal: false
}), []);
function filteredOptions() {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
// stale/blank window.
const opts = Array.isArray(props.options) ? props.options : [];
const df = !!props.disableFilter;
const q = String(query == null ? '' : query);
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
if (foCache.hasVal && foCache.optsRef === opts && foCache.q === q && foCache.df === df) return foCache.val;
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
const val = list.map((o: any, i: any) => ({
value: valueOf(o),
label: labelOf(o),
disabled: disabledOf(o),
_i: i,
id: valueOf(o),
option: o
}));
foCache.optsRef = opts;
foCache.q = q;
foCache.df = df;
foCache.val = val;
foCache.hasVal = true;
return val;
}
function windowSource() {
return filteredOptions();
}
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-combobox-option[data-index]');
for (const el of els as any) virtualizer.current.measureElement(el);
}
function scrollActiveIntoView() {
if (!props.virtual || !virtualizer.current || activeIndex < 0) return;
// 'center' (not 'auto'): keep the active option well inside the rendered slice — '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();
}
function optId(i: any) {
return props.idBase + '-opt-' + i;
}
function listId() {
return props.idBase + '-list';
}
function activeId() {
const list = filteredOptions();
if (isOpen && activeIndex >= 0 && list[activeIndex]) return optId(activeIndex);
return null;
}
function nextEnabled(list: any, from: any, dir: any) {
let i = from;
for (let step = 0; step < list.length; step++) {
i = i + dir;
if (i < 0) i = 0;
if (i >= list.length) i = list.length - 1;
if (list[i] && !list[i].disabled) return i;
if (dir < 0 && i === 0 || dir > 0 && i === list.length - 1) break;
}
return from;
}
const { onChange: _rozieProp_onChange } = props;
const selectOption = useCallback((opt: any) => {
if (!opt || opt.disabled) return;
setValue(opt.value);
setQuery(String(opt.label));
if (props.closeOnSelect) setIsOpen(false);
setActiveIndex(-1);
_rozieProp_onChange && _rozieProp_onChange({
value: opt.value,
option: opt.option
});
}, [_rozieProp_onChange, props.closeOnSelect, setValue]);
const syncQueryToValue = useCallback(() => {
const opts = Array.isArray(props.options) ? props.options : [];
const opt = opts.find((o: any) => o.value === value);
setQuery(opt ? String(opt.label) : '');
}, [props.options, value]);
const { onSearch: _rozieProp_onSearch } = props;
const onInput = useCallback((e: any) => {
const q = e && e.target ? e.target.value : '';
setQuery(q);
setIsOpen(true);
setActiveIndex(0);
_rozieProp_onSearch && _rozieProp_onSearch({
query: q
});
}, [_rozieProp_onSearch]);
const onFocus = useCallback((e: any) => {
setIsOpen(true);
if (e && e.target && e.target.select) e.target.select();
}, []);
const onBlur = useCallback(() => {
setIsOpen(false);
}, []);
const onKeydown = useCallback((e: any) => {
const key = e ? e.key : '';
const list = filteredOptions();
// Capture the reactive reads into locals BEFORE any write so React never binds
// a pre-write value (ROZ138; the read-then-write-same-key idiom). Each branch
// is mutually exclusive, but a flow-insensitive analysis can't see that.
const wasOpen = isOpen;
const ai = activeIndex;
if (key === 'ArrowDown') {
if (e) e.preventDefault();
if (!wasOpen) {
setIsOpen(true);
setActiveIndex(0);
return;
}
setActiveIndex(nextEnabled(list, ai, 1));
} else if (key === 'ArrowUp') {
if (e) e.preventDefault();
if (!wasOpen) {
setIsOpen(true);
return;
}
setActiveIndex(nextEnabled(list, ai, -1));
} else if (key === 'Enter') {
if (wasOpen && ai >= 0 && list[ai]) {
if (e) e.preventDefault();
selectOption(list[ai]);
}
} else if (key === 'Escape') {
if (wasOpen) {
if (e) e.preventDefault();
setIsOpen(false);
}
} else if (key === 'Home') {
if (wasOpen) {
if (e) e.preventDefault();
setActiveIndex(nextEnabled(list, -1, 1));
}
} else if (key === 'End') {
if (wasOpen) {
if (e) e.preventDefault();
setActiveIndex(nextEnabled(list, list.length, -1));
}
}
// Keep the (new) active option in view when windowing — no-op when not virtual.
scrollActiveIntoView();
}, [activeIndex, filteredOptions, isOpen, nextEnabled, scrollActiveIntoView, selectOption]);
const kickWindow = useCallback((attempts: any) => {
if (!virtualizer.current) return;
gridScrollEl.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rozie-combobox-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]);
function focus() {
return inputEl.current?.focus();
}
function clear() {
setValue(null);
setQuery('');
setActiveIndex(-1);
props.onChange && props.onChange({
value: null
});
}
useEffect(() => {
syncQueryToValue();
syncRows();
// ── Windowing: construct the virtualizer (ONLY when virtual) ──────────────
// The windowed popup stays mounted whenever virtual (r-if="$props.virtual"); it is only
// hidden via display:none when closed (CR-01), so the .rozie-combobox-list scroll
// container already exists here for the virtualizer to attach to.
if (props.virtual) {
// Capture the scroll container 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, leaving the virtualizer with no scroll element.
gridScrollEl.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rozie-combobox-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 (virtualizerCleanup.current) virtualizerCleanup.current();
};
}, []);
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
syncQueryToValue();
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
syncRows();
if (props.virtual && virtualizer.current) {
virtualizer.current.setOptions(virtualizerOptions());
virtualizer.current._willUpdate();
setWindowVer(prev => prev + 1);
scheduleRemeasure();
}
}, [props.options, query]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ focus, clear });
_rozieExposeRef.current = { focus, clear };
useImperativeHandle(ref, () => ({ focus: (...args: Parameters<typeof focus>): ReturnType<typeof focus> => _rozieExposeRef.current.focus(...args), clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args) }), []);
return (
<>
<div ref={__rozieRoot} {...attrs} className={clsx(clsx("rozie-combobox", { "rozie-combobox--open": isOpen, "rozie-combobox--disabled": props.disabled, "rozie-combobox--inline": props.inline }), (attrs.className as string | undefined))} data-rozie-s-9546115a="">
<input ref={inputEl} className={"rozie-combobox-input"} type="text" role="combobox" aria-autocomplete="list" aria-expanded={!!isOpen} aria-controls={rozieAttr(listId())} aria-activedescendant={rozieAttr(activeId())} aria-label={rozieAttr(props.ariaLabel)} value={query} placeholder={props.placeholder} disabled={!!props.disabled} autoComplete="off" onInput={($event) => { onInput($event); }} onFocus={($event) => { onFocus($event); }} onBlur={($event) => { onBlur(); }} onKeyDown={($event) => { onKeydown($event); }} data-rozie-s-9546115a="" />
{(isOpen && !props.virtual) && <ul className={"rozie-combobox-list"} id={rozieAttr(listId())} role="listbox" data-rozie-s-9546115a="">
{filteredOptions().map((opt) => <li key={opt.value} className={clsx("rozie-combobox-option", { "rozie-combobox-option--active": opt._i === activeIndex, "rozie-combobox-option--selected": opt.value === value, "rozie-combobox-option--disabled": opt.disabled })} id={rozieAttr(optId(opt._i))} role="option" aria-selected={opt.value === value} aria-disabled={!!opt.disabled} onMouseDown={($event) => { $event.preventDefault(); selectOption(opt); }} onMouseEnter={($event) => { setActiveIndex(opt._i); }} data-rozie-s-9546115a="">
{(props.renderOption ?? props.slots?.['option']) ? ((props.renderOption ?? props.slots?.['option']) as Function)({ option: opt.option, index: opt._i, active: opt._i === activeIndex, selected: opt.value === value, disabled: opt.disabled }) : rozieDisplay(opt.label)}
</li>)}
{(filteredOptions().length === 0) && <li className={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ query }) : "No results"}
</li>}</ul>}{(props.virtual) && <ul className={"rozie-combobox-list rozie-combobox-list--virtual"} id={rozieAttr(listId())} role="listbox" style={parseInlineStyle((isOpen ? '' : 'display:none;') + (props.maxHeight ? 'height:' + props.maxHeight + ';max-height:' + props.maxHeight + ';overflow-y:auto;--rozie-combobox-list-max-height:' + props.maxHeight : 'overflow-y:auto'))} data-rozie-s-9546115a="">
<li className={"rozie-combobox-spacer"} aria-hidden="true" style={parseInlineStyle('height:' + padTop() + 'px')} data-rozie-s-9546115a="" />
{windowedRows().map((wr) => <li key={wr.row.id} className={clsx("rozie-combobox-option", { "rozie-combobox-option--active": wr.vi.index === activeIndex, "rozie-combobox-option--selected": wr.row.value === value, "rozie-combobox-option--disabled": wr.row.disabled })} id={rozieAttr(optId(wr.vi.index))} data-index={rozieAttr(wr.vi.index)} role="option" aria-selected={wr.row.value === value} aria-disabled={!!wr.row.disabled} onMouseDown={($event) => { $event.preventDefault(); selectOption(wr.row); }} onMouseEnter={($event) => { setActiveIndex(wr.vi.index); }} data-rozie-s-9546115a="">
{(props.renderOption ?? props.slots?.['option']) ? ((props.renderOption ?? props.slots?.['option']) as Function)({ option: wr.row.option, index: wr.vi.index, active: wr.vi.index === activeIndex, selected: wr.row.value === value, disabled: wr.row.disabled }) : rozieDisplay(wr.row.label)}
</li>)}
<li className={"rozie-combobox-spacer"} aria-hidden="true" style={parseInlineStyle('height:' + padBottom() + 'px')} data-rozie-s-9546115a="" />
{(windowSource().length === 0) && <li className={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ query }) : "No results"}
</li>}</ul>}</div>
</>
);
});
export default Combobox;vue
<template>
<div :class="['rozie-combobox', { 'rozie-combobox--open': isOpen, 'rozie-combobox--disabled': props.disabled, 'rozie-combobox--inline': props.inline }]" ref="__rozieRootRef" v-bind="$attrs">
<input ref="inputElRef" class="rozie-combobox-input" type="text" role="combobox" aria-autocomplete="list" :aria-expanded="!!isOpen" :aria-controls="listId()" :aria-activedescendant="(activeId()) ?? undefined" :aria-label="props.ariaLabel" :value="query" :placeholder="props.placeholder" :disabled="!!props.disabled" autocomplete="off" @input="onInput($event)" @focus="onFocus($event)" @blur="onBlur()" @keydown="onKeydown($event)" />
<ul v-if="isOpen && !props.virtual" class="rozie-combobox-list" :id="listId()" role="listbox">
<li v-for="opt in filteredOptions()" :key="opt.value" :class="['rozie-combobox-option', { 'rozie-combobox-option--active': opt._i === activeIndex, 'rozie-combobox-option--selected': opt.value === value, 'rozie-combobox-option--disabled': opt.disabled }]" :id="optId(opt._i)" role="option" :aria-selected="opt.value === value" :aria-disabled="!!opt.disabled" @mousedown.prevent="selectOption(opt)" @mouseenter="activeIndex = opt._i">
<slot name="option" :option="opt.option" :index="opt._i" :active="opt._i === activeIndex" :selected="opt.value === value" :disabled="opt.disabled">{{ opt.label }}</slot>
</li>
<li v-if="filteredOptions().length === 0" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="query">No results</slot>
</li></ul><ul v-if="props.virtual" class="rozie-combobox-list rozie-combobox-list--virtual" :id="listId()" role="listbox" :style="(isOpen ? '' : 'display:none;') + (props.maxHeight ? 'height:' + props.maxHeight + ';max-height:' + props.maxHeight + ';overflow-y:auto;--rozie-combobox-list-max-height:' + props.maxHeight : 'overflow-y:auto')">
<li class="rozie-combobox-spacer" aria-hidden="true" :style="'height:' + padTop() + 'px'"></li>
<li v-for="wr in windowedRows()" :key="wr.row.id" :class="['rozie-combobox-option', { 'rozie-combobox-option--active': wr.vi.index === activeIndex, 'rozie-combobox-option--selected': wr.row.value === value, 'rozie-combobox-option--disabled': wr.row.disabled }]" :id="optId(wr.vi.index)" :data-index="wr.vi.index" role="option" :aria-selected="wr.row.value === value" :aria-disabled="!!wr.row.disabled" @mousedown.prevent="selectOption(wr.row)" @mouseenter="activeIndex = wr.vi.index">
<slot name="option" :option="wr.row.option" :index="wr.vi.index" :active="wr.vi.index === activeIndex" :selected="wr.row.value === value" :disabled="wr.row.disabled">{{ wr.row.label }}</slot>
</li>
<li class="rozie-combobox-spacer" aria-hidden="true" :style="'height:' + padBottom() + 'px'"></li>
<li v-if="windowSource().length === 0" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="query">No results</slot>
</li></ul></div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* The option list — `[{ value, label, disabled? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, and an optional `disabled` flag makes an option non-selectable.
*/
options?: any[];
/**
* Placeholder text shown in the input while it is empty.
*/
placeholder?: string;
/**
* Disable the control — the input becomes non-interactive and the popup cannot be opened. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Opt **out** of built-in client filtering (async / server-side mode): render `options` exactly as supplied and rely on the `search` event to refetch. By default the component filters `options` by `label`, case-insensitively, against the typed query.
*/
disableFilter?: boolean;
/**
* Accessible name for the input (`aria-label`), used when there is no visible `<label for>` pointing at it. Provide this (or an external label) so the combobox is announced.
*/
ariaLabel?: string | null;
/**
* Id base for the listbox and option elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`. Set a **distinct** value per instance when more than one combobox shares a page. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase?: string;
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the combobox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
inline?: boolean;
/**
* Close the popup after a selection commits. Defaults `true` (standard autocomplete behavior); set to `false` to keep the popup open after a selection — e.g. when the combobox is embedded in a multi-action surface like a command palette.
*/
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;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling popup (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed combobox. 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 popup scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-combobox-list-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight?: string;
}>(),
{ options: () => [], placeholder: '', disabled: false, disableFilter: false, ariaLabel: null, idBase: 'rozie-combobox', inline: false, closeOnSelect: true, optionLabel: null, optionValue: null, optionDisabled: null, virtual: false, estimateRowHeight: 36, maxHeight: '' }
);
/**
* The selected option's value (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a combobox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `null` when nothing is selected.
* @example
* <Combobox r-model:value="country" :options="countries" />
*/
const value = defineModel<unknown>('value', { default: null });
const emit = defineEmits<{
change: [...args: any[]];
search: [...args: any[]];
}>();
defineSlots<{
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 query = ref('');
const isOpen = ref(false);
const activeIndex = ref(-1);
const rows = ref<any[]>([]);
const windowVer = ref(0);
const editVer = ref(0);
const inputElRef = ref<HTMLInputElement>();
const __rozieRootRef = ref<HTMLElement>();
// ══ 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;
};
// ══ 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;
// 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;
// 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 (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
// Windowing instance state (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
let remeasurePending = false;
// ---- derived view (plain functions, uniform ×6) ------------------------
// The filtered option list, each carrying its filtered-list index `_i`, a stable
// windowing key `id`, and the RAW source option (`option`) so `@change` + the
// `#option` slot expose the original object (CP reads `e.option.id` / `option.group`).
//
// REFERENCE-KEYED MEMO, NOT $computed — this is load-bearing for windowed perf. TanStack
// virtual-core calls getItemKey(i)/getMeasurements O(count) times per pass, and windowSource()
// (below) aliases this, so without a memo every scroll re-`.map()`s ALL options into fresh
// wrapper objects — O(N²). On vue each wrapper read trips a reactive Proxy trap (valueOf/labelOf/
// disabledOf), so a 60-ArrowDown batch over 1,000 options cost ~16s. It is deliberately NOT a
// $computed: a $computed would re-SUBSCRIBE to the reactive `options` Proxy and re-run on
// unrelated reactive churn (and on vue re-trip the Proxy traps); the whole point is to AVOID
// re-mapping when only activeIndex changed. The cache key is pure VALUE/REFERENCE comparison
// (no reactive subscription), so it adds zero reactivity churn — it collapses virtual-core's
// O(count) re-maps to ONE map per real (options-ref / query / disableFilter) change.
//
// foCache is a member-mutated FRESH-OBJECT const (NOT a reassigned `let`): the React emitter
// lowers `const X = {…}` that is member-mutated to `useMemo(() => ({…}), [])` (per-instance,
// stable across renders — feedback_react_const_mutinstance_not_stabilized); on the 5 setup-once
// targets the top-level const persists for the instance lifetime naturally. A reassigned
// `let X = null` would NOT survive React renders (filteredOptions() is reached from the TEMPLATE,
// not a hook-root → per-render reset trap), so it MUST be a fresh-object const.
// ---- derived view (plain functions, uniform ×6) ------------------------
// The filtered option list, each carrying its filtered-list index `_i`, a stable
// windowing key `id`, and the RAW source option (`option`) so `@change` + the
// `#option` slot expose the original object (CP reads `e.option.id` / `option.group`).
//
// REFERENCE-KEYED MEMO, NOT $computed — this is load-bearing for windowed perf. TanStack
// virtual-core calls getItemKey(i)/getMeasurements O(count) times per pass, and windowSource()
// (below) aliases this, so without a memo every scroll re-`.map()`s ALL options into fresh
// wrapper objects — O(N²). On vue each wrapper read trips a reactive Proxy trap (valueOf/labelOf/
// disabledOf), so a 60-ArrowDown batch over 1,000 options cost ~16s. It is deliberately NOT a
// $computed: a $computed would re-SUBSCRIBE to the reactive `options` Proxy and re-run on
// unrelated reactive churn (and on vue re-trip the Proxy traps); the whole point is to AVOID
// re-mapping when only activeIndex changed. The cache key is pure VALUE/REFERENCE comparison
// (no reactive subscription), so it adds zero reactivity churn — it collapses virtual-core's
// O(count) re-maps to ONE map per real (options-ref / query / disableFilter) change.
//
// foCache is a member-mutated FRESH-OBJECT const (NOT a reassigned `let`): the React emitter
// lowers `const X = {…}` that is member-mutated to `useMemo(() => ({…}), [])` (per-instance,
// stable across renders — feedback_react_const_mutinstance_not_stabilized); on the 5 setup-once
// targets the top-level const persists for the instance lifetime naturally. A reassigned
// `let X = null` would NOT survive React renders (filteredOptions() is reached from the TEMPLATE,
// not a hook-root → per-render reset trap), so it MUST be a fresh-object const.
const foCache = {
optsRef: null,
q: null,
df: null,
val: null,
hasVal: false
};
const filteredOptions = () => {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
// stale/blank window.
const opts = Array.isArray(props.options) ? props.options : [];
const df = !!props.disableFilter;
const q = String(query.value == null ? '' : query.value);
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
if (foCache.hasVal && foCache.optsRef === opts && foCache.q === q && foCache.df === df) return foCache.val;
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
const val = list.map((o: any, i: any) => ({
value: valueOf(o),
label: labelOf(o),
disabled: disabledOf(o),
_i: i,
id: valueOf(o),
option: o
}));
foCache.optsRef = opts;
foCache.q = q;
foCache.df = df;
foCache.val = val;
foCache.hasVal = true;
return val;
};
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// list (the same wrapper rows the template iterates). Kept === $data.rows so the math's
// rowList[vi.index] resolves to the same wrapper the count windows over.
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// list (the same wrapper rows the template iterates). Kept === $data.rows so the math's
// rowList[vi.index] resolves to the same wrapper the count windows over.
const windowSource = () => filteredOptions();
// 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 combobox 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 combobox 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 filtered set.
// Keep $data.rows === windowSource() so the windowing math indexes the live filtered set.
const syncRows = () => {
rows.value = windowSource();
};
// Defer remeasureWindow() until AFTER the framework commits the recycled window: TWO
// passes (microtask THEN rAF) behind one in-flight flag (the data-table
// virtualization.rzts pattern, copied per-consumer per D-04/D-09) — microtask catches
// Solid's <For> / Svelte's {#each} synchronous commit (the Phase 63 Solid
// under-convergence hazard — D-09 rAF-defer budget), rAF catches React's async commit.
// Defer remeasureWindow() until AFTER the framework commits the recycled window: TWO
// passes (microtask THEN rAF) behind one in-flight flag (the data-table
// virtualization.rzts pattern, copied per-consumer per D-04/D-09) — microtask catches
// Solid's <For> / Svelte's {#each} synchronous commit (the Phase 63 Solid
// under-convergence hazard — D-09 rAF-defer budget), rAF catches React's async commit.
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 height is observed (virtual-core measures ONLY nodes passed to measureElement,
// keyed by the data-index attribute). Bails during a programmatic scroll.
// measureElement sweep: hand every rendered windowed option to the virtualizer so its
// true height is observed (virtual-core measures ONLY nodes passed to measureElement,
// keyed by the data-index attribute). Bails during a programmatic scroll.
const remeasureWindow = () => {
if (!virtualizer || !gridScrollEl) return;
if (virtualizer.scrollState) return;
const els = gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]');
for (const el of els as any) virtualizer.measureElement(el);
};
// Keep the active option visible inside the windowed popup. When windowing, route
// through the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered
// window scrolls into view (the windowed-arrow-nav seam). No-op when not virtual (the
// non-virtual combobox popup is short enough not to need it — unchanged behavior).
// Keep the active option visible inside the windowed popup. When windowing, route
// through the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered
// window scrolls into view (the windowed-arrow-nav seam). No-op when not virtual (the
// non-virtual combobox popup is short enough not to need it — unchanged behavior).
const scrollActiveIntoView = () => {
if (!props.virtual || !virtualizer || activeIndex.value < 0) return;
// 'center' (not 'auto'): keep the active option well inside the rendered slice — '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();
};
const optId = (i: any) => props.idBase + '-opt-' + i;
const listId = () => props.idBase + '-list';
// The active option's id for aria-activedescendant (null when none).
// The active option's id for aria-activedescendant (null when none).
const activeId = () => {
const list = filteredOptions();
if (isOpen.value && activeIndex.value >= 0 && list[activeIndex.value]) return optId(activeIndex.value);
return null;
};
// Next selectable index in `dir` (+1/-1), skipping disabled, clamped to ends.
// Next selectable index in `dir` (+1/-1), skipping disabled, clamped to ends.
const nextEnabled = (list: any, from: any, dir: any) => {
let i = from;
for (let step = 0; step < list.length; step++) {
i = i + dir;
if (i < 0) i = 0;
if (i >= list.length) i = list.length - 1;
if (list[i] && !list[i].disabled) return i;
if (dir < 0 && i === 0 || dir > 0 && i === list.length - 1) break;
}
return from;
};
// ---- selection (writes the model + syncs query) ------------------------
// `opt` is a filtered-row wrapper ({ value, label, disabled, _i, option }). Fire
// `@change` with BOTH the committed value AND the raw source `option` (CP reads
// `e.option`). `closeOnSelect` (default true) gates the popup close — a caller
// embedding the combobox in a multi-action surface passes `:close-on-select="false"`.
// ---- selection (writes the model + syncs query) ------------------------
// `opt` is a filtered-row wrapper ({ value, label, disabled, _i, option }). Fire
// `@change` with BOTH the committed value AND the raw source `option` (CP reads
// `e.option`). `closeOnSelect` (default true) gates the popup close — a caller
// embedding the combobox in a multi-action surface passes `:close-on-select="false"`.
const selectOption = (opt: any) => {
if (!opt || opt.disabled) return;
value.value = opt.value;
query.value = String(opt.label);
if (props.closeOnSelect) isOpen.value = false;
activeIndex.value = -1;
emit('change', {
value: opt.value,
option: opt.option
});
};
// Reflect the externally-selected value into the input text.
// Reflect the externally-selected value into the input text.
const syncQueryToValue = () => {
const opts = Array.isArray(props.options) ? props.options : [];
const opt = opts.find((o: any) => o.value === value.value);
query.value = opt ? String(opt.label) : '';
};
// ---- input + keyboard handlers -----------------------------------------
// ---- input + keyboard handlers -----------------------------------------
const onInput = (e: any) => {
const q = e && e.target ? e.target.value : '';
query.value = q;
isOpen.value = true;
activeIndex.value = 0;
emit('search', {
query: q
});
};
const onFocus = (e: any) => {
isOpen.value = true;
if (e && e.target && e.target.select) e.target.select();
};
// @blur closes the popup. Option selection uses @mousedown.prevent, which keeps
// focus on the input, so a click on an option does NOT blur-close before select.
// @blur closes the popup. Option selection uses @mousedown.prevent, which keeps
// focus on the input, so a click on an option does NOT blur-close before select.
const onBlur = () => {
isOpen.value = false;
};
const onKeydown = (e: any) => {
const key = e ? e.key : '';
const list = filteredOptions();
// Capture the reactive reads into locals BEFORE any write so React never binds
// a pre-write value (ROZ138; the read-then-write-same-key idiom). Each branch
// is mutually exclusive, but a flow-insensitive analysis can't see that.
const wasOpen = isOpen.value;
const ai = activeIndex.value;
if (key === 'ArrowDown') {
if (e) e.preventDefault();
if (!wasOpen) {
isOpen.value = true;
activeIndex.value = 0;
return;
}
activeIndex.value = nextEnabled(list, ai, 1);
} else if (key === 'ArrowUp') {
if (e) e.preventDefault();
if (!wasOpen) {
isOpen.value = true;
return;
}
activeIndex.value = nextEnabled(list, ai, -1);
} else if (key === 'Enter') {
if (wasOpen && ai >= 0 && list[ai]) {
if (e) e.preventDefault();
selectOption(list[ai]);
}
} else if (key === 'Escape') {
if (wasOpen) {
if (e) e.preventDefault();
isOpen.value = false;
}
} else if (key === 'Home') {
if (wasOpen) {
if (e) e.preventDefault();
activeIndex.value = nextEnabled(list, -1, 1);
}
} else if (key === 'End') {
if (wasOpen) {
if (e) e.preventDefault();
activeIndex.value = nextEnabled(list, list.length, -1);
}
}
// Keep the (new) active option in view when windowing — no-op when not virtual.
scrollActiveIntoView();
};
// ---- lifecycle + imperative handle -------------------------------------
// kickWindow: the cross-target first-paint settle (the data-table / listbox precedent).
// Re-captures the LIVE scroll element, re-feeds the CURRENT option count, re-attaches the
// rect observer (_willUpdate), and bumps the windowVer signal so the windowed slice
// 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 (stale scrollElement),
// and (c) the consumer often seeds options AFTER the combobox mounts (Lit/React). Stops once
// the window paints — idempotent + loop-free.
// ---- lifecycle + imperative handle -------------------------------------
// kickWindow: the cross-target first-paint settle (the data-table / listbox precedent).
// Re-captures the LIVE scroll element, re-feeds the CURRENT option count, re-attaches the
// rect observer (_willUpdate), and bumps the windowVer signal so the windowed slice
// 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 (stale scrollElement),
// and (c) the consumer often seeds options AFTER the combobox mounts (Lit/React). Stops once
// the window paints — idempotent + loop-free.
const kickWindow = (attempts: any) => {
if (!virtualizer) return;
gridScrollEl = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rozie-combobox-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);
}
};
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. Both post-mount → $refs safe.
const focus = () => inputElRef.value?.focus();
const clear = () => {
value.value = null;
query.value = '';
activeIndex.value = -1;
emit('change', {
value: null
});
};
onMounted(() => {
syncQueryToValue();
syncRows();
// ── Windowing: construct the virtualizer (ONLY when virtual) ──────────────
// The windowed popup stays mounted whenever virtual (r-if="$props.virtual"); it is only
// hidden via display:none when closed (CR-01), so the .rozie-combobox-list scroll
// container already exists here for the virtualizer to attach to.
if (props.virtual) {
// Capture the scroll container 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, leaving the virtualizer with no scroll element.
gridScrollEl = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rozie-combobox-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 (virtualizerCleanup) virtualizerCleanup();
});
watch(() => value.value, () => {
syncQueryToValue();
});
watch(() => (props.options ? props.options.length : 0) + '|' + query.value, () => {
syncRows();
if (props.virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
windowVer.value = windowVer.value + 1;
scheduleRemeasure();
}
});
defineExpose({ focus, clear });
</script>
<style scoped>
.rozie-combobox {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, 16rem);
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input {
box-sizing: border-box;
width: 100%;
padding: var(--rozie-combobox-input-padding, 0.5rem 0.75rem);
font: inherit;
color: var(--rozie-combobox-color, inherit);
background: var(--rozie-combobox-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-border-color, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-combobox-radius, 0.5rem);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input:focus {
border-color: var(--rozie-combobox-accent, #0066cc);
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, 3px) var(--rozie-combobox-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-combobox--disabled .rozie-combobox-input {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, 0.55);
background: var(--rozie-combobox-disabled-bg, rgba(0, 0, 0, 0.04));
}
.rozie-combobox-list {
position: absolute;
z-index: var(--rozie-combobox-list-z, 50);
top: calc(100% + var(--rozie-combobox-list-gap, 0.25rem));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-combobox-list-padding, 0.25rem);
list-style: none;
max-height: var(--rozie-combobox-list-max-height, 16rem);
overflow-y: auto;
background: var(--rozie-combobox-list-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-list-border-color, rgba(0, 0, 0, 0.15));
border-radius: var(--rozie-combobox-radius, 0.5rem);
box-shadow: var(--rozie-combobox-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
}
.rozie-combobox-option {
padding: var(--rozie-combobox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-combobox-option-radius, 0.375rem);
cursor: pointer;
color: var(--rozie-combobox-option-color, inherit);
}
.rozie-combobox-option--active {
background: var(--rozie-combobox-option-active-bg, rgba(0, 102, 204, 0.12));
}
.rozie-combobox-option--selected {
font-weight: var(--rozie-combobox-option-selected-weight, 600);
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, #0066cc));
}
.rozie-combobox-option--disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, 0.45);
}
.rozie-combobox-empty {
padding: var(--rozie-combobox-empty-padding, 0.5rem 0.6rem);
color: var(--rozie-combobox-empty-color, rgba(0, 0, 0, 0.5));
list-style: none;
}
.rozie-combobox-spacer { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox--inline {
display: block;
width: 100%;
}
.rozie-combobox--inline .rozie-combobox-list {
position: static;
margin-top: var(--rozie-combobox-list-gap, 0.25rem);
border: none;
border-radius: 0;
box-shadow: 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 selected option's value (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a combobox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `null` when nothing is selected.
* @example
* <Combobox r-model:value="country" :options="countries" />
*/
value?: (unknown) | null;
/**
* The option list — `[{ value, label, disabled? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, and an optional `disabled` flag makes an option non-selectable.
*/
options?: any[];
/**
* Placeholder text shown in the input while it is empty.
*/
placeholder?: string;
/**
* Disable the control — the input becomes non-interactive and the popup cannot be opened. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Opt **out** of built-in client filtering (async / server-side mode): render `options` exactly as supplied and rely on the `search` event to refetch. By default the component filters `options` by `label`, case-insensitively, against the typed query.
*/
disableFilter?: boolean;
/**
* Accessible name for the input (`aria-label`), used when there is no visible `<label for>` pointing at it. Provide this (or an external label) so the combobox is announced.
*/
ariaLabel?: (string) | null;
/**
* Id base for the listbox and option elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`. Set a **distinct** value per instance when more than one combobox shares a page. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase?: string;
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the combobox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
inline?: boolean;
/**
* Close the popup after a selection commits. Defaults `true` (standard autocomplete behavior); set to `false` to keep the popup open after a selection — e.g. when the combobox is embedded in a multi-action surface like a command palette.
*/
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;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling popup (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed combobox. 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 popup scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-combobox-list-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight?: string;
option?: Snippet<[{ option: any; index: any; active: any; selected: any; disabled: any }]>;
empty?: Snippet<[{ query: any }]>;
snippets?: Record<string, any>;
onchange?: (...args: unknown[]) => void;
onsearch?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let __defaultOptions = (() => [])();
let {
value = $bindable(null),
options = __defaultOptions,
placeholder = '',
disabled = false,
disableFilter = false,
ariaLabel = null,
idBase = 'rozie-combobox',
inline = false,
closeOnSelect = true,
optionLabel = null,
optionValue = null,
optionDisabled = null,
virtual = false,
estimateRowHeight = 36,
maxHeight = '',
option: __optionProp,
empty: __emptyProp,
snippets,
onchange,
onsearch,
...__rozieAttrs
}: Props = $props();
const option = $derived(__optionProp ?? snippets?.option);
const empty = $derived(__emptyProp ?? snippets?.empty);
let query = $state('');
let isOpen = $state(false);
let activeIndex = $state(-1);
let rows: any[] = $state([]);
let windowVer = $state(0);
let editVer = $state(0);
let inputEl = $state<HTMLInputElement | undefined>(undefined);
let __rozieRoot = $state<HTMLElement | undefined>(undefined);
// ══ 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;
};
// ══ 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;
// 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;
// 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 (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
// Windowing instance state (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
let remeasurePending = false;
// ---- derived view (plain functions, uniform ×6) ------------------------
// The filtered option list, each carrying its filtered-list index `_i`, a stable
// windowing key `id`, and the RAW source option (`option`) so `@change` + the
// `#option` slot expose the original object (CP reads `e.option.id` / `option.group`).
//
// REFERENCE-KEYED MEMO, NOT $computed — this is load-bearing for windowed perf. TanStack
// virtual-core calls getItemKey(i)/getMeasurements O(count) times per pass, and windowSource()
// (below) aliases this, so without a memo every scroll re-`.map()`s ALL options into fresh
// wrapper objects — O(N²). On vue each wrapper read trips a reactive Proxy trap (valueOf/labelOf/
// disabledOf), so a 60-ArrowDown batch over 1,000 options cost ~16s. It is deliberately NOT a
// $computed: a $computed would re-SUBSCRIBE to the reactive `options` Proxy and re-run on
// unrelated reactive churn (and on vue re-trip the Proxy traps); the whole point is to AVOID
// re-mapping when only activeIndex changed. The cache key is pure VALUE/REFERENCE comparison
// (no reactive subscription), so it adds zero reactivity churn — it collapses virtual-core's
// O(count) re-maps to ONE map per real (options-ref / query / disableFilter) change.
//
// foCache is a member-mutated FRESH-OBJECT const (NOT a reassigned `let`): the React emitter
// lowers `const X = {…}` that is member-mutated to `useMemo(() => ({…}), [])` (per-instance,
// stable across renders — feedback_react_const_mutinstance_not_stabilized); on the 5 setup-once
// targets the top-level const persists for the instance lifetime naturally. A reassigned
// `let X = null` would NOT survive React renders (filteredOptions() is reached from the TEMPLATE,
// not a hook-root → per-render reset trap), so it MUST be a fresh-object const.
// ---- derived view (plain functions, uniform ×6) ------------------------
// The filtered option list, each carrying its filtered-list index `_i`, a stable
// windowing key `id`, and the RAW source option (`option`) so `@change` + the
// `#option` slot expose the original object (CP reads `e.option.id` / `option.group`).
//
// REFERENCE-KEYED MEMO, NOT $computed — this is load-bearing for windowed perf. TanStack
// virtual-core calls getItemKey(i)/getMeasurements O(count) times per pass, and windowSource()
// (below) aliases this, so without a memo every scroll re-`.map()`s ALL options into fresh
// wrapper objects — O(N²). On vue each wrapper read trips a reactive Proxy trap (valueOf/labelOf/
// disabledOf), so a 60-ArrowDown batch over 1,000 options cost ~16s. It is deliberately NOT a
// $computed: a $computed would re-SUBSCRIBE to the reactive `options` Proxy and re-run on
// unrelated reactive churn (and on vue re-trip the Proxy traps); the whole point is to AVOID
// re-mapping when only activeIndex changed. The cache key is pure VALUE/REFERENCE comparison
// (no reactive subscription), so it adds zero reactivity churn — it collapses virtual-core's
// O(count) re-maps to ONE map per real (options-ref / query / disableFilter) change.
//
// foCache is a member-mutated FRESH-OBJECT const (NOT a reassigned `let`): the React emitter
// lowers `const X = {…}` that is member-mutated to `useMemo(() => ({…}), [])` (per-instance,
// stable across renders — feedback_react_const_mutinstance_not_stabilized); on the 5 setup-once
// targets the top-level const persists for the instance lifetime naturally. A reassigned
// `let X = null` would NOT survive React renders (filteredOptions() is reached from the TEMPLATE,
// not a hook-root → per-render reset trap), so it MUST be a fresh-object const.
const foCache = {
optsRef: null,
q: null,
df: null,
val: null,
hasVal: false
};
const filteredOptions = () => {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
// stale/blank window.
const opts = Array.isArray(options) ? options : [];
const df = !!disableFilter;
const q = String(query == null ? '' : query);
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
if (foCache.hasVal && foCache.optsRef === opts && foCache.q === q && foCache.df === df) return foCache.val;
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
const val = list.map((o: any, i: any) => ({
value: valueOf(o),
label: labelOf(o),
disabled: disabledOf(o),
_i: i,
id: valueOf(o),
option: o
}));
foCache.optsRef = opts;
foCache.q = q;
foCache.df = df;
foCache.val = val;
foCache.hasVal = true;
return val;
};
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// list (the same wrapper rows the template iterates). Kept === $data.rows so the math's
// rowList[vi.index] resolves to the same wrapper the count windows over.
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// list (the same wrapper rows the template iterates). Kept === $data.rows so the math's
// rowList[vi.index] resolves to the same wrapper the count windows over.
const windowSource = () => filteredOptions();
// 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 combobox 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 combobox 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 filtered set.
// Keep $data.rows === windowSource() so the windowing math indexes the live filtered set.
const syncRows = () => {
rows = windowSource();
};
// Defer remeasureWindow() until AFTER the framework commits the recycled window: TWO
// passes (microtask THEN rAF) behind one in-flight flag (the data-table
// virtualization.rzts pattern, copied per-consumer per D-04/D-09) — microtask catches
// Solid's <For> / Svelte's {#each} synchronous commit (the Phase 63 Solid
// under-convergence hazard — D-09 rAF-defer budget), rAF catches React's async commit.
// Defer remeasureWindow() until AFTER the framework commits the recycled window: TWO
// passes (microtask THEN rAF) behind one in-flight flag (the data-table
// virtualization.rzts pattern, copied per-consumer per D-04/D-09) — microtask catches
// Solid's <For> / Svelte's {#each} synchronous commit (the Phase 63 Solid
// under-convergence hazard — D-09 rAF-defer budget), rAF catches React's async commit.
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 height is observed (virtual-core measures ONLY nodes passed to measureElement,
// keyed by the data-index attribute). Bails during a programmatic scroll.
// measureElement sweep: hand every rendered windowed option to the virtualizer so its
// true height is observed (virtual-core measures ONLY nodes passed to measureElement,
// keyed by the data-index attribute). Bails during a programmatic scroll.
const remeasureWindow = () => {
if (!virtualizer || !gridScrollEl) return;
if (virtualizer.scrollState) return;
const els = gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]');
for (const el of els as any) virtualizer.measureElement(el);
};
// Keep the active option visible inside the windowed popup. When windowing, route
// through the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered
// window scrolls into view (the windowed-arrow-nav seam). No-op when not virtual (the
// non-virtual combobox popup is short enough not to need it — unchanged behavior).
// Keep the active option visible inside the windowed popup. When windowing, route
// through the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered
// window scrolls into view (the windowed-arrow-nav seam). No-op when not virtual (the
// non-virtual combobox popup is short enough not to need it — unchanged behavior).
const scrollActiveIntoView = () => {
if (!virtual || !virtualizer || activeIndex < 0) return;
// 'center' (not 'auto'): keep the active option well inside the rendered slice — '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();
};
const optId = (i: any) => idBase + '-opt-' + i;
const listId = () => idBase + '-list';
// The active option's id for aria-activedescendant (null when none).
// The active option's id for aria-activedescendant (null when none).
const activeId = () => {
const list = filteredOptions();
if (isOpen && activeIndex >= 0 && list[activeIndex]) return optId(activeIndex);
return null;
};
// Next selectable index in `dir` (+1/-1), skipping disabled, clamped to ends.
// Next selectable index in `dir` (+1/-1), skipping disabled, clamped to ends.
const nextEnabled = (list: any, from: any, dir: any) => {
let i = from;
for (let step = 0; step < list.length; step++) {
i = i + dir;
if (i < 0) i = 0;
if (i >= list.length) i = list.length - 1;
if (list[i] && !list[i].disabled) return i;
if (dir < 0 && i === 0 || dir > 0 && i === list.length - 1) break;
}
return from;
};
// ---- selection (writes the model + syncs query) ------------------------
// `opt` is a filtered-row wrapper ({ value, label, disabled, _i, option }). Fire
// `@change` with BOTH the committed value AND the raw source `option` (CP reads
// `e.option`). `closeOnSelect` (default true) gates the popup close — a caller
// embedding the combobox in a multi-action surface passes `:close-on-select="false"`.
// ---- selection (writes the model + syncs query) ------------------------
// `opt` is a filtered-row wrapper ({ value, label, disabled, _i, option }). Fire
// `@change` with BOTH the committed value AND the raw source `option` (CP reads
// `e.option`). `closeOnSelect` (default true) gates the popup close — a caller
// embedding the combobox in a multi-action surface passes `:close-on-select="false"`.
const selectOption = (opt: any) => {
if (!opt || opt.disabled) return;
value = opt.value;
query = String(opt.label);
if (closeOnSelect) isOpen = false;
activeIndex = -1;
onchange?.({
value: opt.value,
option: opt.option
});
};
// Reflect the externally-selected value into the input text.
// Reflect the externally-selected value into the input text.
const syncQueryToValue = () => {
const opts = Array.isArray(options) ? options : [];
const opt = opts.find((o: any) => o.value === value);
query = opt ? String(opt.label) : '';
};
// ---- input + keyboard handlers -----------------------------------------
// ---- input + keyboard handlers -----------------------------------------
const onInput = (e: any) => {
const q = e && e.target ? e.target.value : '';
query = q;
isOpen = true;
activeIndex = 0;
onsearch?.({
query: q
});
};
const onFocus = (e: any) => {
isOpen = true;
if (e && e.target && e.target.select) e.target.select();
};
// @blur closes the popup. Option selection uses @mousedown.prevent, which keeps
// focus on the input, so a click on an option does NOT blur-close before select.
// @blur closes the popup. Option selection uses @mousedown.prevent, which keeps
// focus on the input, so a click on an option does NOT blur-close before select.
const onBlur = () => {
isOpen = false;
};
const onKeydown = (e: any) => {
const key = e ? e.key : '';
const list = filteredOptions();
// Capture the reactive reads into locals BEFORE any write so React never binds
// a pre-write value (ROZ138; the read-then-write-same-key idiom). Each branch
// is mutually exclusive, but a flow-insensitive analysis can't see that.
const wasOpen = isOpen;
const ai = activeIndex;
if (key === 'ArrowDown') {
if (e) e.preventDefault();
if (!wasOpen) {
isOpen = true;
activeIndex = 0;
return;
}
activeIndex = nextEnabled(list, ai, 1);
} else if (key === 'ArrowUp') {
if (e) e.preventDefault();
if (!wasOpen) {
isOpen = true;
return;
}
activeIndex = nextEnabled(list, ai, -1);
} else if (key === 'Enter') {
if (wasOpen && ai >= 0 && list[ai]) {
if (e) e.preventDefault();
selectOption(list[ai]);
}
} else if (key === 'Escape') {
if (wasOpen) {
if (e) e.preventDefault();
isOpen = false;
}
} else if (key === 'Home') {
if (wasOpen) {
if (e) e.preventDefault();
activeIndex = nextEnabled(list, -1, 1);
}
} else if (key === 'End') {
if (wasOpen) {
if (e) e.preventDefault();
activeIndex = nextEnabled(list, list.length, -1);
}
}
// Keep the (new) active option in view when windowing — no-op when not virtual.
scrollActiveIntoView();
};
// ---- lifecycle + imperative handle -------------------------------------
// kickWindow: the cross-target first-paint settle (the data-table / listbox precedent).
// Re-captures the LIVE scroll element, re-feeds the CURRENT option count, re-attaches the
// rect observer (_willUpdate), and bumps the windowVer signal so the windowed slice
// 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 (stale scrollElement),
// and (c) the consumer often seeds options AFTER the combobox mounts (Lit/React). Stops once
// the window paints — idempotent + loop-free.
// ---- lifecycle + imperative handle -------------------------------------
// kickWindow: the cross-target first-paint settle (the data-table / listbox precedent).
// Re-captures the LIVE scroll element, re-feeds the CURRENT option count, re-attaches the
// rect observer (_willUpdate), and bumps the windowVer signal so the windowed slice
// 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 (stale scrollElement),
// and (c) the consumer often seeds options AFTER the combobox mounts (Lit/React). Stops once
// the window paints — idempotent + loop-free.
const kickWindow = (attempts: any) => {
if (!virtualizer) return;
gridScrollEl = __rozieRoot ? __rozieRoot!.querySelector('.rozie-combobox-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);
}
};
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. Both post-mount → $refs safe.
export const focus = () => inputEl?.focus();
export const clear = () => {
value = null;
query = '';
activeIndex = -1;
onchange?.({
value: null
});
};
onMount(() => {
syncQueryToValue();
syncRows();
// ── Windowing: construct the virtualizer (ONLY when virtual) ──────────────
// The windowed popup stays mounted whenever virtual (r-if="$props.virtual"); it is only
// hidden via display:none when closed (CR-01), so the .rozie-combobox-list scroll
// container already exists here for the virtualizer to attach to.
if (virtual) {
// Capture the scroll container 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, leaving the virtualizer with no scroll element.
gridScrollEl = __rozieRoot ? __rozieRoot!.querySelector('.rozie-combobox-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 (virtualizerCleanup) virtualizerCleanup();
})());
let __rozieWatchInitial_0 = true;
$effect(() => { (() => value)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } (() => {
syncQueryToValue();
})(); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { (() => (options ? options.length : 0) + '|' + query)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => {
syncRows();
if (virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
windowVer = windowVer + 1;
scheduleRemeasure();
}
})(); }); });
</script>
<div bind:this={__rozieRoot} {...__rozieAttrs} class={["rozie-combobox", { 'rozie-combobox--open': isOpen, 'rozie-combobox--disabled': disabled, 'rozie-combobox--inline': inline }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-9546115a><input bind:this={inputEl} class="rozie-combobox-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded={!!isOpen} aria-controls={rozieAttr(listId())} aria-activedescendant={rozieAttr(activeId())} aria-label={ariaLabel} value={query} placeholder={placeholder} disabled={!!disabled} autocomplete="off" oninput={($event) => { onInput($event); }} onfocus={($event) => { onFocus($event); }} onblur={($event) => { onBlur(); }} onkeydown={($event) => { onKeydown($event); }} data-rozie-s-9546115a />{#if isOpen && !virtual}<ul class="rozie-combobox-list" id={rozieAttr(listId())} role="listbox" data-rozie-s-9546115a>{#each filteredOptions() as opt (opt.value)}<li class={["rozie-combobox-option", { 'rozie-combobox-option--active': opt._i === activeIndex, 'rozie-combobox-option--selected': opt.value === value, 'rozie-combobox-option--disabled': opt.disabled }]} id={rozieAttr(optId(opt._i))} role="option" aria-selected={opt.value === value} aria-disabled={!!opt.disabled} onmousedown={($event) => { $event.preventDefault(); selectOption(opt); }} onmouseenter={($event) => { activeIndex = opt._i; }} data-rozie-s-9546115a>{#if option}{@render option({ option: opt.option, index: opt._i, active: opt._i === activeIndex, selected: opt.value === value, disabled: opt.disabled })}{:else}{rozieDisplay(opt.label)}{/if}</li>{/each}{#if filteredOptions().length === 0}<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>{#if empty}{@render empty({ query })}{:else}No results{/if}</li>{/if}</ul>{/if}{#if virtual}<ul class="rozie-combobox-list rozie-combobox-list--virtual" id={rozieAttr(listId())} role="listbox" style={rozieStyle((isOpen ? '' : 'display:none;') + (maxHeight ? 'height:' + maxHeight + ';max-height:' + maxHeight + ';overflow-y:auto;--rozie-combobox-list-max-height:' + maxHeight : 'overflow-y:auto'))} data-rozie-s-9546115a><li class="rozie-combobox-spacer" aria-hidden="true" style={rozieStyle('height:' + padTop() + 'px')} data-rozie-s-9546115a></li>{#each windowedRows() as wr (wr.row.id)}<li class={["rozie-combobox-option", { 'rozie-combobox-option--active': wr.vi.index === activeIndex, 'rozie-combobox-option--selected': wr.row.value === value, 'rozie-combobox-option--disabled': wr.row.disabled }]} id={rozieAttr(optId(wr.vi.index))} data-index={rozieAttr(wr.vi.index)} role="option" aria-selected={wr.row.value === value} aria-disabled={!!wr.row.disabled} onmousedown={($event) => { $event.preventDefault(); selectOption(wr.row); }} onmouseenter={($event) => { activeIndex = wr.vi.index; }} data-rozie-s-9546115a>{#if option}{@render option({ option: wr.row.option, index: wr.vi.index, active: wr.vi.index === activeIndex, selected: wr.row.value === value, disabled: wr.row.disabled })}{:else}{rozieDisplay(wr.row.label)}{/if}</li>{/each}<li class="rozie-combobox-spacer" aria-hidden="true" style={rozieStyle('height:' + padBottom() + 'px')} data-rozie-s-9546115a></li>{#if windowSource().length === 0}<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>{#if empty}{@render empty({ query })}{:else}No results{/if}</li>{/if}</ul>{/if}</div>
<style>
:global {
.rozie-combobox[data-rozie-s-9546115a] {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, 16rem);
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input[data-rozie-s-9546115a] {
box-sizing: border-box;
width: 100%;
padding: var(--rozie-combobox-input-padding, 0.5rem 0.75rem);
font: inherit;
color: var(--rozie-combobox-color, inherit);
background: var(--rozie-combobox-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-border-color, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-combobox-radius, 0.5rem);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input[data-rozie-s-9546115a]:focus {
border-color: var(--rozie-combobox-accent, #0066cc);
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, 3px) var(--rozie-combobox-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-combobox--disabled[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, 0.55);
background: var(--rozie-combobox-disabled-bg, rgba(0, 0, 0, 0.04));
}
.rozie-combobox-list[data-rozie-s-9546115a] {
position: absolute;
z-index: var(--rozie-combobox-list-z, 50);
top: calc(100% + var(--rozie-combobox-list-gap, 0.25rem));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-combobox-list-padding, 0.25rem);
list-style: none;
max-height: var(--rozie-combobox-list-max-height, 16rem);
overflow-y: auto;
background: var(--rozie-combobox-list-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-list-border-color, rgba(0, 0, 0, 0.15));
border-radius: var(--rozie-combobox-radius, 0.5rem);
box-shadow: var(--rozie-combobox-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
}
.rozie-combobox-option[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-combobox-option-radius, 0.375rem);
cursor: pointer;
color: var(--rozie-combobox-option-color, inherit);
}
.rozie-combobox-option--active[data-rozie-s-9546115a] {
background: var(--rozie-combobox-option-active-bg, rgba(0, 102, 204, 0.12));
}
.rozie-combobox-option--selected[data-rozie-s-9546115a] {
font-weight: var(--rozie-combobox-option-selected-weight, 600);
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, #0066cc));
}
.rozie-combobox-option--disabled[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, 0.45);
}
.rozie-combobox-empty[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-empty-padding, 0.5rem 0.6rem);
color: var(--rozie-combobox-empty-color, rgba(0, 0, 0, 0.5));
list-style: none;
}
.rozie-combobox-spacer[data-rozie-s-9546115a] { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox--inline[data-rozie-s-9546115a] {
display: block;
width: 100%;
}
.rozie-combobox--inline[data-rozie-s-9546115a] .rozie-combobox-list[data-rozie-s-9546115a] {
position: static;
margin-top: var(--rozie-combobox-list-gap, 0.25rem);
border: none;
border-radius: 0;
box-shadow: none;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, 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;
// 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 (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
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-combobox',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<div class="rozie-combobox" [ngClass]="{ 'rozie-combobox--open': isOpen(), 'rozie-combobox--disabled': (disabled() || this.__rozieCvaDisabled()), 'rozie-combobox--inline': inline() }" #__rozieRoot #rozieSpread_0 #rozieListenersTarget_1>
<input #inputEl class="rozie-combobox-input" type="text" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="!!isOpen()" [attr.aria-controls]="rozieAttr(listId())" [attr.aria-activedescendant]="rozieAttr(activeId())" [attr.aria-label]="ariaLabel()" [value]="query()" [placeholder]="placeholder()" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" autocomplete="off" (input)="onInput($event)" (focus)="onFocus($event)" (blur)="onBlur()" (keydown)="onKeydown($event)" />
@if (isOpen() && !virtual()) {
<ul class="rozie-combobox-list" [attr.id]="rozieAttr(listId())" role="listbox">
@for (opt of filteredOptions(); track opt.value) {
<li class="rozie-combobox-option" [ngClass]="{ 'rozie-combobox-option--active': opt._i === activeIndex(), 'rozie-combobox-option--selected': opt.value === value(), 'rozie-combobox-option--disabled': opt.disabled }" [attr.id]="rozieAttr(optId(opt._i))" role="option" [attr.aria-selected]="opt.value === value()" [attr.aria-disabled]="!!opt.disabled" (mousedown)="$event.preventDefault(); selectOption(opt)" (mouseenter)="activeIndex.set(opt._i)">
@if ((optionTpl ?? templates()?.['option'])) {
<ng-container *ngTemplateOutlet="(optionTpl ?? templates()?.['option']); context: { $implicit: { option: opt.option, index: opt._i, active: opt._i === activeIndex(), selected: opt.value === value(), disabled: opt.disabled }, option: opt.option, index: opt._i, active: opt._i === activeIndex(), selected: opt.value === value(), disabled: opt.disabled }" />
} @else {
{{ rozieDisplay(opt.label) }}
}
</li>
}
@if (filteredOptions().length === 0) {
<li class="rozie-combobox-empty" role="presentation">
@if ((emptyTpl ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? templates()?.['empty']); context: { $implicit: { query: query() }, query: query() }" />
} @else {
No results
}
</li>
}</ul>
}@if (virtual()) {
<ul class="rozie-combobox-list rozie-combobox-list--virtual" [attr.id]="rozieAttr(listId())" role="listbox" [style]="__style">
<li class="rozie-combobox-spacer" aria-hidden="true" [style]="'height:' + padTop() + 'px'"></li>
@for (wr of windowedRows(); track wr.row.id) {
<li class="rozie-combobox-option" [ngClass]="{ 'rozie-combobox-option--active': wr.vi.index === activeIndex(), 'rozie-combobox-option--selected': wr.row.value === value(), 'rozie-combobox-option--disabled': wr.row.disabled }" [attr.id]="rozieAttr(optId(wr.vi.index))" [attr.data-index]="rozieAttr(wr.vi.index)" role="option" [attr.aria-selected]="wr.row.value === value()" [attr.aria-disabled]="!!wr.row.disabled" (mousedown)="$event.preventDefault(); selectOption(wr.row)" (mouseenter)="activeIndex.set(wr.vi.index)">
@if ((optionTpl ?? templates()?.['option'])) {
<ng-container *ngTemplateOutlet="(optionTpl ?? templates()?.['option']); context: { $implicit: { option: wr.row.option, index: wr.vi.index, active: wr.vi.index === activeIndex(), selected: wr.row.value === value(), disabled: wr.row.disabled }, option: wr.row.option, index: wr.vi.index, active: wr.vi.index === activeIndex(), selected: wr.row.value === value(), disabled: wr.row.disabled }" />
} @else {
{{ rozieDisplay(wr.row.label) }}
}
</li>
}
<li class="rozie-combobox-spacer" aria-hidden="true" [style]="'height:' + padBottom() + 'px'"></li>
@if (windowSource().length === 0) {
<li class="rozie-combobox-empty" role="presentation">
@if ((emptyTpl ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? templates()?.['empty']); context: { $implicit: { query: query() }, query: query() }" />
} @else {
No results
}
</li>
}</ul>
}</div>
`,
styles: [`
.rozie-combobox {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, 16rem);
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input {
box-sizing: border-box;
width: 100%;
padding: var(--rozie-combobox-input-padding, 0.5rem 0.75rem);
font: inherit;
color: var(--rozie-combobox-color, inherit);
background: var(--rozie-combobox-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-border-color, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-combobox-radius, 0.5rem);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input:focus {
border-color: var(--rozie-combobox-accent, #0066cc);
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, 3px) var(--rozie-combobox-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-combobox--disabled .rozie-combobox-input {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, 0.55);
background: var(--rozie-combobox-disabled-bg, rgba(0, 0, 0, 0.04));
}
.rozie-combobox-list {
position: absolute;
z-index: var(--rozie-combobox-list-z, 50);
top: calc(100% + var(--rozie-combobox-list-gap, 0.25rem));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-combobox-list-padding, 0.25rem);
list-style: none;
max-height: var(--rozie-combobox-list-max-height, 16rem);
overflow-y: auto;
background: var(--rozie-combobox-list-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-list-border-color, rgba(0, 0, 0, 0.15));
border-radius: var(--rozie-combobox-radius, 0.5rem);
box-shadow: var(--rozie-combobox-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
}
.rozie-combobox-option {
padding: var(--rozie-combobox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-combobox-option-radius, 0.375rem);
cursor: pointer;
color: var(--rozie-combobox-option-color, inherit);
}
.rozie-combobox-option--active {
background: var(--rozie-combobox-option-active-bg, rgba(0, 102, 204, 0.12));
}
.rozie-combobox-option--selected {
font-weight: var(--rozie-combobox-option-selected-weight, 600);
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, #0066cc));
}
.rozie-combobox-option--disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, 0.45);
}
.rozie-combobox-empty {
padding: var(--rozie-combobox-empty-padding, 0.5rem 0.6rem);
color: var(--rozie-combobox-empty-color, rgba(0, 0, 0, 0.5));
list-style: none;
}
.rozie-combobox-spacer { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox--inline {
display: block;
width: 100%;
}
.rozie-combobox--inline .rozie-combobox-list {
position: static;
margin-top: var(--rozie-combobox-list-gap, 0.25rem);
border: none;
border-radius: 0;
box-shadow: none;
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Combobox),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Combobox {
/**
* The selected option's value (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a combobox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `null` when nothing is selected.
* @example
* <Combobox r-model:value="country" :options="countries" />
*/
value = model<(unknown) | null>(null);
/**
* The option list — `[{ value, label, disabled? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, and an optional `disabled` flag makes an option non-selectable.
*/
options = input<any[]>((() => [])());
/**
* Placeholder text shown in the input while it is empty.
*/
placeholder = input<string>('');
/**
* Disable the control — the input becomes non-interactive and the popup cannot be opened. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled = input<boolean>(false);
/**
* Opt **out** of built-in client filtering (async / server-side mode): render `options` exactly as supplied and rely on the `search` event to refetch. By default the component filters `options` by `label`, case-insensitively, against the typed query.
*/
disableFilter = input<boolean>(false);
/**
* Accessible name for the input (`aria-label`), used when there is no visible `<label for>` pointing at it. Provide this (or an external label) so the combobox is announced.
*/
ariaLabel = input<(string) | null>(null);
/**
* Id base for the listbox and option elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`. Set a **distinct** value per instance when more than one combobox shares a page. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase = input<string>('rozie-combobox');
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the combobox 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);
/**
* Close the popup after a selection commits. Defaults `true` (standard autocomplete behavior); set to `false` to keep the popup open after a selection — e.g. when the combobox is embedded in a multi-action surface like a command palette.
*/
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);
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling popup (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed combobox. 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 popup scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-combobox-list-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight = input<string>('');
query = signal('');
isOpen = signal(false);
activeIndex = signal(-1);
rows = signal<any[]>([]);
windowVer = signal(0);
editVer = signal(0);
inputEl = viewChild<ElementRef<HTMLInputElement>>('inputEl');
__rozieRoot = viewChild<ElementRef<HTMLDivElement>>('__rozieRoot');
change = output<unknown>();
search = output<unknown>();
@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;
private __rozieWatchInitial_1 = true;
constructor() {
inject(DestroyRef).onDestroy(() => {
if (this.virtualizerCleanup) this.virtualizerCleanup();
});
effect(() => { const __watchVal = (() => this.value())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
this.syncQueryToValue();
})(); }); });
effect(() => { const __watchVal = (() => (this.options() ? this.options().length : 0) + '|' + this.query())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
this.syncRows();
if (this.virtual() && this.virtualizer) {
this.virtualizer.setOptions(this.virtualizerOptions());
this.virtualizer._willUpdate();
this.windowVer.set(this.windowVer() + 1);
this.scheduleRemeasure();
}
})(); }); });
}
ngAfterViewInit() {
this.syncQueryToValue();
this.syncRows();
// ── Windowing: construct the virtualizer (ONLY when virtual) ──────────────
// The windowed popup stays mounted whenever virtual (r-if="$props.virtual"); it is only
// hidden via display:none when closed (CR-01), so the .rozie-combobox-list scroll
// container already exists here for the virtualizer to attach to.
// ── Windowing: construct the virtualizer (ONLY when virtual) ──────────────
// The windowed popup stays mounted whenever virtual (r-if="$props.virtual"); it is only
// hidden via display:none when closed (CR-01), so the .rozie-combobox-list scroll
// container already exists here for the virtualizer to attach to.
if (this.virtual()) {
// Capture the scroll container 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, leaving the virtualizer with no scroll element.
this.gridScrollEl = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rozie-combobox-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);
}
}
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;
};
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;
foCache = {
optsRef: null,
q: null,
df: null,
val: null,
hasVal: false
};
filteredOptions = () => {
const __options = this.options();
const __query = this.query();
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
// stale/blank window.
const opts = Array.isArray(__options) ? __options : [];
const df = !!this.disableFilter();
const q = String(__query == null ? '' : __query);
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
if (this.foCache.hasVal && this.foCache.optsRef === opts && this.foCache.q === q && this.foCache.df === df) return this.foCache.val;
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(this.labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
const val = list.map((o: any, i: any) => ({
value: this.valueOf$local(o),
label: this.labelOf(o),
disabled: this.disabledOf(o),
_i: i,
id: this.valueOf$local(o),
option: o
}));
this.foCache.optsRef = opts;
this.foCache.q = q;
this.foCache.df = df;
this.foCache.val = val;
this.foCache.hasVal = true;
return val;
};
windowSource = () => this.filteredOptions();
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-combobox-option[data-index]');
for (const el of els as any) this.virtualizer.measureElement(el);
};
scrollActiveIntoView = () => {
const __activeIndex = this.activeIndex();
if (!this.virtual() || !this.virtualizer || __activeIndex < 0) return;
// 'center' (not 'auto'): keep the active option well inside the rendered slice — '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();
};
optId = (i: any) => this.idBase() + '-opt-' + i;
listId = () => this.idBase() + '-list';
activeId = () => {
const __activeIndex = this.activeIndex();
const list = this.filteredOptions();
if (this.isOpen() && __activeIndex >= 0 && list[__activeIndex]) return this.optId(__activeIndex);
return null;
};
nextEnabled = (list: any, from: any, dir: any) => {
let i = from;
for (let step = 0; step < list.length; step++) {
i = i + dir;
if (i < 0) i = 0;
if (i >= list.length) i = list.length - 1;
if (list[i] && !list[i].disabled) return i;
if (dir < 0 && i === 0 || dir > 0 && i === list.length - 1) break;
}
return from;
};
selectOption = (opt: any) => {
if (!opt || opt.disabled) return;
this.value.set(opt.value), this.__rozieCvaOnChange(opt.value);
this.query.set(String(opt.label));
if (this.closeOnSelect()) this.isOpen.set(false);
this.activeIndex.set(-1);
this.change.emit({
value: opt.value,
option: opt.option
});
};
syncQueryToValue = () => {
const __options = this.options();
const opts = Array.isArray(__options) ? __options : [];
const opt = opts.find((o: any) => o.value === this.value());
this.query.set(opt ? String(opt.label) : '');
};
onInput = (e: any) => {
const q = e && e.target ? e.target.value : '';
this.query.set(q);
this.isOpen.set(true);
this.activeIndex.set(0);
this.search.emit({
query: q
});
};
onFocus = (e: any) => {
this.isOpen.set(true);
if (e && e.target && e.target.select) e.target.select();
};
onBlur = () => {
this.isOpen.set(false);
};
onKeydown = (e: any) => {
const key = e ? e.key : '';
const list = this.filteredOptions();
// Capture the reactive reads into locals BEFORE any write so React never binds
// a pre-write value (ROZ138; the read-then-write-same-key idiom). Each branch
// is mutually exclusive, but a flow-insensitive analysis can't see that.
const wasOpen = this.isOpen();
const ai = this.activeIndex();
if (key === 'ArrowDown') {
if (e) e.preventDefault();
if (!wasOpen) {
this.isOpen.set(true);
this.activeIndex.set(0);
return;
}
this.activeIndex.set(this.nextEnabled(list, ai, 1));
} else if (key === 'ArrowUp') {
if (e) e.preventDefault();
if (!wasOpen) {
this.isOpen.set(true);
return;
}
this.activeIndex.set(this.nextEnabled(list, ai, -1));
} else if (key === 'Enter') {
if (wasOpen && ai >= 0 && list[ai]) {
if (e) e.preventDefault();
this.selectOption(list[ai]);
}
} else if (key === 'Escape') {
if (wasOpen) {
if (e) e.preventDefault();
this.isOpen.set(false);
}
} else if (key === 'Home') {
if (wasOpen) {
if (e) e.preventDefault();
this.activeIndex.set(this.nextEnabled(list, -1, 1));
}
} else if (key === 'End') {
if (wasOpen) {
if (e) e.preventDefault();
this.activeIndex.set(this.nextEnabled(list, list.length, -1));
}
}
// Keep the (new) active option in view when windowing — no-op when not virtual.
this.scrollActiveIntoView();
};
kickWindow = (attempts: any) => {
if (!this.virtualizer) return;
this.gridScrollEl = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rozie-combobox-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);
}
};
focus = () => this.inputEl()?.nativeElement?.focus();
clear = () => {
this.value.set(null), this.__rozieCvaOnChange(null);
this.query.set('');
this.activeIndex.set(-1);
this.change.emit({
value: null
});
};
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: Combobox,
_ctx: unknown,
): _ctx is 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.isOpen() ? '' : 'display:none;') + (__maxHeight ? 'height:' + __maxHeight + ';max-height:' + __maxHeight + ';overflow-y:auto;--rozie-combobox-list-max-height:' + __maxHeight : 'overflow-y:auto');
}
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default Combobox;tsx
import type { JSX } from 'solid-js';
import { For, Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, 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;
// 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 (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
__rozieInjectStyle('Combobox-9546115a', `.rozie-combobox[data-rozie-s-9546115a] {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, 16rem);
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input[data-rozie-s-9546115a] {
box-sizing: border-box;
width: 100%;
padding: var(--rozie-combobox-input-padding, 0.5rem 0.75rem);
font: inherit;
color: var(--rozie-combobox-color, inherit);
background: var(--rozie-combobox-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-border-color, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-combobox-radius, 0.5rem);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input[data-rozie-s-9546115a]:focus {
border-color: var(--rozie-combobox-accent, #0066cc);
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, 3px) var(--rozie-combobox-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-combobox--disabled[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, 0.55);
background: var(--rozie-combobox-disabled-bg, rgba(0, 0, 0, 0.04));
}
.rozie-combobox-list[data-rozie-s-9546115a] {
position: absolute;
z-index: var(--rozie-combobox-list-z, 50);
top: calc(100% + var(--rozie-combobox-list-gap, 0.25rem));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-combobox-list-padding, 0.25rem);
list-style: none;
max-height: var(--rozie-combobox-list-max-height, 16rem);
overflow-y: auto;
background: var(--rozie-combobox-list-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-list-border-color, rgba(0, 0, 0, 0.15));
border-radius: var(--rozie-combobox-radius, 0.5rem);
box-shadow: var(--rozie-combobox-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
}
.rozie-combobox-option[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-combobox-option-radius, 0.375rem);
cursor: pointer;
color: var(--rozie-combobox-option-color, inherit);
}
.rozie-combobox-option--active[data-rozie-s-9546115a] {
background: var(--rozie-combobox-option-active-bg, rgba(0, 102, 204, 0.12));
}
.rozie-combobox-option--selected[data-rozie-s-9546115a] {
font-weight: var(--rozie-combobox-option-selected-weight, 600);
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, #0066cc));
}
.rozie-combobox-option--disabled[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, 0.45);
}
.rozie-combobox-empty[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-empty-padding, 0.5rem 0.6rem);
color: var(--rozie-combobox-empty-color, rgba(0, 0, 0, 0.5));
list-style: none;
}
.rozie-combobox-spacer[data-rozie-s-9546115a] { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox--inline[data-rozie-s-9546115a] {
display: block;
width: 100%;
}
.rozie-combobox--inline[data-rozie-s-9546115a] .rozie-combobox-list[data-rozie-s-9546115a] {
position: static;
margin-top: var(--rozie-combobox-list-gap, 0.25rem);
border: none;
border-radius: 0;
box-shadow: none;
}`);
interface OptionSlotCtx { option: any; index: any; active: any; selected: any; disabled: any; }
interface EmptySlotCtx { query: any; }
interface ComboboxProps {
/**
* The selected option's value (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a combobox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `null` when nothing is selected.
* @example
* <Combobox r-model:value="country" :options="countries" />
*/
value?: (unknown) | null;
defaultValue?: (unknown) | null;
onValueChange?: (value: (unknown) | null) => void;
/**
* The option list — `[{ value, label, disabled? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, and an optional `disabled` flag makes an option non-selectable.
*/
options?: any[];
/**
* Placeholder text shown in the input while it is empty.
*/
placeholder?: string;
/**
* Disable the control — the input becomes non-interactive and the popup cannot be opened. Also sets the Angular `ControlValueAccessor` disabled state.
*/
disabled?: boolean;
/**
* Opt **out** of built-in client filtering (async / server-side mode): render `options` exactly as supplied and rely on the `search` event to refetch. By default the component filters `options` by `label`, case-insensitively, against the typed query.
*/
disableFilter?: boolean;
/**
* Accessible name for the input (`aria-label`), used when there is no visible `<label for>` pointing at it. Provide this (or an external label) so the combobox is announced.
*/
ariaLabel?: (string) | null;
/**
* Id base for the listbox and option elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`. Set a **distinct** value per instance when more than one combobox shares a page. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase?: string;
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the combobox inside an `overflow:hidden` container (e.g. a command palette) so the list is not clipped. Defaults `false` (standalone dropdown behavior).
*/
inline?: boolean;
/**
* Close the popup after a selection commits. Defaults `true` (standard autocomplete behavior); set to `false` to keep the popup open after a selection — e.g. when the combobox is embedded in a multi-action surface like a command palette.
*/
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;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling popup (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed combobox. 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 popup scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-combobox-list-max-height` custom property; the prop wins, the token is the fallback. Ignored when `virtual` is off.
*/
maxHeight?: string;
onChange?: (...args: unknown[]) => void;
onSearch?: (...args: unknown[]) => void;
optionSlot?: (ctx: OptionSlotCtx) => JSX.Element;
emptySlot?: (ctx: EmptySlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: ComboboxHandle) => void;
}
export interface ComboboxHandle {
focus: (...args: any[]) => any;
clear: (...args: any[]) => any;
}
export default function Combobox(_props: ComboboxProps): JSX.Element {
const _merged = mergeProps({ options: (() => [])(), placeholder: '', disabled: false, disableFilter: false, ariaLabel: null, idBase: 'rozie-combobox', inline: false, closeOnSelect: true, optionLabel: null, optionValue: null, optionDisabled: null, virtual: false, estimateRowHeight: 36, maxHeight: '' }, _props);
const [local, attrs] = splitProps(_merged, ['value', 'options', 'placeholder', 'disabled', 'disableFilter', 'ariaLabel', 'idBase', 'inline', 'closeOnSelect', 'optionLabel', 'optionValue', 'optionDisabled', 'virtual', 'estimateRowHeight', 'maxHeight', 'ref']);
onMount(() => { local.ref?.({ focus, clear }); });
const [value, setValue] = createControllableSignal<unknown>(_props as unknown as Record<string, unknown>, 'value', null);
const [query, setQuery] = createSignal('');
const [isOpen, setIsOpen] = createSignal(false);
const [activeIndex, setActiveIndex] = createSignal(-1);
const [rows, setRows] = createSignal<any[]>([]);
const [windowVer, setWindowVer] = createSignal(0);
const [editVer, setEditVer] = createSignal(0);
onMount(() => {
syncQueryToValue();
syncRows();
// ── Windowing: construct the virtualizer (ONLY when virtual) ──────────────
// The windowed popup stays mounted whenever virtual (r-if="$props.virtual"); it is only
// hidden via display:none when closed (CR-01), so the .rozie-combobox-list scroll
// container already exists here for the virtualizer to attach to.
if (local.virtual) {
// Capture the scroll container 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, leaving the virtualizer with no scroll element.
gridScrollEl = __rozieRootRef! ? __rozieRootRef!.querySelector('.rozie-combobox-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 (virtualizerCleanup) virtualizerCleanup();
});
createEffect(on(() => (() => value())(), (v) => untrack(() => (() => {
syncQueryToValue();
})()), { defer: true }));
createEffect(on(() => (() => (local.options ? local.options.length : 0) + '|' + query())(), (v) => untrack(() => (() => {
syncRows();
if (local.virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
setWindowVer(windowVer() + 1);
scheduleRemeasure();
}
})()), { defer: true }));
let inputElRef: HTMLElement | null = null;
let __rozieRootRef: HTMLElement | null = null;
// ══ 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;
}
// ══ 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 (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
let remeasurePending = false;
// ---- derived view (plain functions, uniform ×6) ------------------------
// The filtered option list, each carrying its filtered-list index `_i`, a stable
// windowing key `id`, and the RAW source option (`option`) so `@change` + the
// `#option` slot expose the original object (CP reads `e.option.id` / `option.group`).
//
// REFERENCE-KEYED MEMO, NOT $computed — this is load-bearing for windowed perf. TanStack
// virtual-core calls getItemKey(i)/getMeasurements O(count) times per pass, and windowSource()
// (below) aliases this, so without a memo every scroll re-`.map()`s ALL options into fresh
// wrapper objects — O(N²). On vue each wrapper read trips a reactive Proxy trap (valueOf/labelOf/
// disabledOf), so a 60-ArrowDown batch over 1,000 options cost ~16s. It is deliberately NOT a
// $computed: a $computed would re-SUBSCRIBE to the reactive `options` Proxy and re-run on
// unrelated reactive churn (and on vue re-trip the Proxy traps); the whole point is to AVOID
// re-mapping when only activeIndex changed. The cache key is pure VALUE/REFERENCE comparison
// (no reactive subscription), so it adds zero reactivity churn — it collapses virtual-core's
// O(count) re-maps to ONE map per real (options-ref / query / disableFilter) change.
//
// foCache is a member-mutated FRESH-OBJECT const (NOT a reassigned `let`): the React emitter
// lowers `const X = {…}` that is member-mutated to `useMemo(() => ({…}), [])` (per-instance,
// stable across renders — feedback_react_const_mutinstance_not_stabilized); on the 5 setup-once
// targets the top-level const persists for the instance lifetime naturally. A reassigned
// `let X = null` would NOT survive React renders (filteredOptions() is reached from the TEMPLATE,
// not a hook-root → per-render reset trap), so it MUST be a fresh-object const.
const foCache = {
optsRef: null,
q: null,
df: null,
val: null,
hasVal: false
};
function filteredOptions() {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
// stale/blank window.
const opts = Array.isArray(local.options) ? local.options : [];
const df = !!local.disableFilter;
const q = String(query() == null ? '' : query());
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
if (foCache.hasVal && foCache.optsRef === opts && foCache.q === q && foCache.df === df) return foCache.val;
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
const val = list.map((o: any, i: any) => ({
value: valueOf(o),
label: labelOf(o),
disabled: disabledOf(o),
_i: i,
id: valueOf(o),
option: o
}));
foCache.optsRef = opts;
foCache.q = q;
foCache.df = df;
foCache.val = val;
foCache.hasVal = true;
return val;
}
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option
// list (the same wrapper rows the template iterates). Kept === $data.rows so the math's
// rowList[vi.index] resolves to the same wrapper the count windows over.
function windowSource() {
return filteredOptions();
}
// 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 combobox 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 filtered set.
function syncRows() {
setRows(windowSource());
}
// Defer remeasureWindow() until AFTER the framework commits the recycled window: TWO
// passes (microtask THEN rAF) behind one in-flight flag (the data-table
// virtualization.rzts pattern, copied per-consumer per D-04/D-09) — microtask catches
// Solid's <For> / Svelte's {#each} synchronous commit (the Phase 63 Solid
// under-convergence hazard — D-09 rAF-defer budget), rAF catches React's async commit.
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 height is observed (virtual-core measures ONLY nodes passed to measureElement,
// keyed by the data-index attribute). Bails during a programmatic scroll.
function remeasureWindow() {
if (!virtualizer || !gridScrollEl) return;
if (virtualizer.scrollState) return;
const els = gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]');
for (const el of els as any) virtualizer.measureElement(el);
}
// Keep the active option visible inside the windowed popup. When windowing, route
// through the virtualizer (scrollToIndex) so an active option OUTSIDE the rendered
// window scrolls into view (the windowed-arrow-nav seam). No-op when not virtual (the
// non-virtual combobox popup is short enough not to need it — unchanged behavior).
function scrollActiveIntoView() {
if (!local.virtual || !virtualizer || activeIndex() < 0) return;
// 'center' (not 'auto'): keep the active option well inside the rendered slice — '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();
}
function optId(i: any) {
return local.idBase + '-opt-' + i;
}
function listId() {
return local.idBase + '-list';
}
// The active option's id for aria-activedescendant (null when none).
function activeId() {
const list = filteredOptions();
if (isOpen() && activeIndex() >= 0 && list[activeIndex()]) return optId(activeIndex());
return null;
}
// Next selectable index in `dir` (+1/-1), skipping disabled, clamped to ends.
function nextEnabled(list: any, from: any, dir: any) {
let i = from;
for (let step = 0; step < list.length; step++) {
i = i + dir;
if (i < 0) i = 0;
if (i >= list.length) i = list.length - 1;
if (list[i] && !list[i].disabled) return i;
if (dir < 0 && i === 0 || dir > 0 && i === list.length - 1) break;
}
return from;
}
// ---- selection (writes the model + syncs query) ------------------------
// `opt` is a filtered-row wrapper ({ value, label, disabled, _i, option }). Fire
// `@change` with BOTH the committed value AND the raw source `option` (CP reads
// `e.option`). `closeOnSelect` (default true) gates the popup close — a caller
// embedding the combobox in a multi-action surface passes `:close-on-select="false"`.
function selectOption(opt: any) {
if (!opt || opt.disabled) return;
setValue(opt.value);
setQuery(String(opt.label));
if (local.closeOnSelect) setIsOpen(false);
setActiveIndex(-1);
_props.onChange?.({
value: opt.value,
option: opt.option
});
}
// Reflect the externally-selected value into the input text.
function syncQueryToValue() {
const opts = Array.isArray(local.options) ? local.options : [];
const opt = opts.find((o: any) => o.value === value());
setQuery(opt ? String(opt.label) : '');
}
// ---- input + keyboard handlers -----------------------------------------
function onInput(e: any) {
const q = e && e.target ? e.target.value : '';
setQuery(q);
setIsOpen(true);
setActiveIndex(0);
_props.onSearch?.({
query: q
});
}
function onFocus(e: any) {
setIsOpen(true);
if (e && e.target && e.target.select) e.target.select();
}
// @blur closes the popup. Option selection uses @mousedown.prevent, which keeps
// focus on the input, so a click on an option does NOT blur-close before select.
function onBlur() {
setIsOpen(false);
}
function onKeydown(e: any) {
const key = e ? e.key : '';
const list = filteredOptions();
// Capture the reactive reads into locals BEFORE any write so React never binds
// a pre-write value (ROZ138; the read-then-write-same-key idiom). Each branch
// is mutually exclusive, but a flow-insensitive analysis can't see that.
const wasOpen = isOpen();
const ai = activeIndex();
if (key === 'ArrowDown') {
if (e) e.preventDefault();
if (!wasOpen) {
setIsOpen(true);
setActiveIndex(0);
return;
}
setActiveIndex(nextEnabled(list, ai, 1));
} else if (key === 'ArrowUp') {
if (e) e.preventDefault();
if (!wasOpen) {
setIsOpen(true);
return;
}
setActiveIndex(nextEnabled(list, ai, -1));
} else if (key === 'Enter') {
if (wasOpen && ai >= 0 && list[ai]) {
if (e) e.preventDefault();
selectOption(list[ai]);
}
} else if (key === 'Escape') {
if (wasOpen) {
if (e) e.preventDefault();
setIsOpen(false);
}
} else if (key === 'Home') {
if (wasOpen) {
if (e) e.preventDefault();
setActiveIndex(nextEnabled(list, -1, 1));
}
} else if (key === 'End') {
if (wasOpen) {
if (e) e.preventDefault();
setActiveIndex(nextEnabled(list, list.length, -1));
}
}
// Keep the (new) active option in view when windowing — no-op when not virtual.
scrollActiveIntoView();
}
// ---- lifecycle + imperative handle -------------------------------------
// kickWindow: the cross-target first-paint settle (the data-table / listbox precedent).
// Re-captures the LIVE scroll element, re-feeds the CURRENT option count, re-attaches the
// rect observer (_willUpdate), and bumps the windowVer signal so the windowed slice
// 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 (stale scrollElement),
// and (c) the consumer often seeds options AFTER the combobox mounts (Lit/React). Stops once
// the window paints — idempotent + loop-free.
function kickWindow(attempts: any) {
if (!virtualizer) return;
gridScrollEl = __rozieRootRef! ? __rozieRootRef!.querySelector('.rozie-combobox-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);
}
}
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. Both post-mount → $refs safe.
function focus() {
return inputElRef?.focus();
}
function clear() {
setValue(null);
setQuery('');
setActiveIndex(-1);
_props.onChange?.({
value: null
});
}
return (
<>
<div ref={(el) => { __rozieRootRef = el as HTMLElement; }} {...attrs} class={"rozie-combobox" + " " + rozieClass({ 'rozie-combobox--open': isOpen(), 'rozie-combobox--disabled': local.disabled, 'rozie-combobox--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-9546115a="">
<input type="text" role="combobox" aria-autocomplete="list" aria-expanded={!!isOpen()} aria-controls={rozieAttr(listId())} aria-activedescendant={rozieAttr(activeId())} aria-label={rozieAttr(local.ariaLabel)} autocomplete="off" ref={(el) => { inputElRef = el as HTMLElement; }} class={"rozie-combobox-input"} value={query()} placeholder={local.placeholder} disabled={!!local.disabled} onInput={($event) => { onInput($event); }} onFocus={($event) => { onFocus($event); }} onBlur={($event) => { onBlur(); }} onKeyDown={($event) => { onKeydown($event); }} data-rozie-s-9546115a="" />
{<Show when={isOpen() && !local.virtual}><ul class={"rozie-combobox-list"} id={rozieAttr(listId())} role="listbox" data-rozie-s-9546115a="">
<For each={filteredOptions()}>{(opt) => <li role="option" aria-selected={opt.value === value()} aria-disabled={!!opt.disabled} class={"rozie-combobox-option" + " " + rozieClass({ 'rozie-combobox-option--active': opt._i === activeIndex(), 'rozie-combobox-option--selected': opt.value === value(), 'rozie-combobox-option--disabled': opt.disabled })} id={rozieAttr(optId(opt._i))} onMouseDown={($event) => { $event.preventDefault(); selectOption(opt); }} onMouseEnter={($event) => { setActiveIndex(opt._i); }} data-rozie-s-9546115a="">
{(_props.optionSlot ?? _props.slots?.['option'])?.({ option: opt.option, index: opt._i, active: opt._i === activeIndex(), selected: opt.value === value(), disabled: opt.disabled }) ?? rozieDisplay(opt.label)}
</li>}</For>
{<Show when={filteredOptions().length === 0}><li class={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(_props.emptySlot ?? _props.slots?.['empty'])?.({ query: query() }) ?? "No results"}
</li></Show>}</ul></Show>}{<Show when={local.virtual}><ul class={"rozie-combobox-list rozie-combobox-list--virtual"} id={rozieAttr(listId())} role="listbox" style={parseInlineStyle((isOpen() ? '' : 'display:none;') + (local.maxHeight ? 'height:' + local.maxHeight + ';max-height:' + local.maxHeight + ';overflow-y:auto;--rozie-combobox-list-max-height:' + local.maxHeight : 'overflow-y:auto'))} data-rozie-s-9546115a="">
<li class={"rozie-combobox-spacer"} aria-hidden="true" style={parseInlineStyle('height:' + padTop() + 'px')} data-rozie-s-9546115a="" />
<For each={windowedRows()}>{(wr) => <li data-index={rozieAttr(wr.vi.index)} role="option" aria-selected={wr.row.value === value()} aria-disabled={!!wr.row.disabled} class={"rozie-combobox-option" + " " + rozieClass({ 'rozie-combobox-option--active': wr.vi.index === activeIndex(), 'rozie-combobox-option--selected': wr.row.value === value(), 'rozie-combobox-option--disabled': wr.row.disabled })} id={rozieAttr(optId(wr.vi.index))} onMouseDown={($event) => { $event.preventDefault(); selectOption(wr.row); }} onMouseEnter={($event) => { setActiveIndex(wr.vi.index); }} data-rozie-s-9546115a="">
{(_props.optionSlot ?? _props.slots?.['option'])?.({ option: wr.row.option, index: wr.vi.index, active: wr.vi.index === activeIndex(), selected: wr.row.value === value(), disabled: wr.row.disabled }) ?? rozieDisplay(wr.row.label)}
</li>}</For>
<li class={"rozie-combobox-spacer"} aria-hidden="true" style={parseInlineStyle('height:' + padBottom() + 'px')} data-rozie-s-9546115a="" />
{<Show when={windowSource().length === 0}><li class={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(_props.emptySlot ?? _props.slots?.['empty'])?.({ query: query() }) ?? "No results"}
</li></Show>}</ul></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 { 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;
// 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 (reassigned module-`let`s → React hoists to useRef; do NOT
// const). NULL until $onMount, ONLY constructed when $props.virtual. gridScrollEl is the
// captured .rozie-combobox-list scroll div; remeasurePending dedupes the deferred sweep.
interface RozieOptionSlotCtx {
option: unknown;
index: unknown;
active: unknown;
selected: unknown;
disabled: unknown;
}
interface RozieEmptySlotCtx {
query: unknown;
}
@customElement('rozie-combobox')
export default class Combobox extends SignalWatcher(LitElement) {
static styles = css`
.rozie-combobox[data-rozie-s-9546115a] {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, 16rem);
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input[data-rozie-s-9546115a] {
box-sizing: border-box;
width: 100%;
padding: var(--rozie-combobox-input-padding, 0.5rem 0.75rem);
font: inherit;
color: var(--rozie-combobox-color, inherit);
background: var(--rozie-combobox-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-border-color, rgba(0, 0, 0, 0.25));
border-radius: var(--rozie-combobox-radius, 0.5rem);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input[data-rozie-s-9546115a]:focus {
border-color: var(--rozie-combobox-accent, #0066cc);
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, 3px) var(--rozie-combobox-focus-ring-color, rgba(0, 102, 204, 0.25));
}
.rozie-combobox--disabled[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, 0.55);
background: var(--rozie-combobox-disabled-bg, rgba(0, 0, 0, 0.04));
}
.rozie-combobox-list[data-rozie-s-9546115a] {
position: absolute;
z-index: var(--rozie-combobox-list-z, 50);
top: calc(100% + var(--rozie-combobox-list-gap, 0.25rem));
left: 0;
right: 0;
margin: 0;
padding: var(--rozie-combobox-list-padding, 0.25rem);
list-style: none;
max-height: var(--rozie-combobox-list-max-height, 16rem);
overflow-y: auto;
background: var(--rozie-combobox-list-bg, #fff);
border: var(--rozie-combobox-border-width, 1px) solid var(--rozie-combobox-list-border-color, rgba(0, 0, 0, 0.15));
border-radius: var(--rozie-combobox-radius, 0.5rem);
box-shadow: var(--rozie-combobox-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
}
.rozie-combobox-option[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-option-padding, 0.4rem 0.6rem);
border-radius: var(--rozie-combobox-option-radius, 0.375rem);
cursor: pointer;
color: var(--rozie-combobox-option-color, inherit);
}
.rozie-combobox-option--active[data-rozie-s-9546115a] {
background: var(--rozie-combobox-option-active-bg, rgba(0, 102, 204, 0.12));
}
.rozie-combobox-option--selected[data-rozie-s-9546115a] {
font-weight: var(--rozie-combobox-option-selected-weight, 600);
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, #0066cc));
}
.rozie-combobox-option--disabled[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, 0.45);
}
.rozie-combobox-empty[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-empty-padding, 0.5rem 0.6rem);
color: var(--rozie-combobox-empty-color, rgba(0, 0, 0, 0.5));
list-style: none;
}
.rozie-combobox-spacer[data-rozie-s-9546115a] { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox--inline[data-rozie-s-9546115a] {
display: block;
width: 100%;
}
.rozie-combobox--inline[data-rozie-s-9546115a] .rozie-combobox-list[data-rozie-s-9546115a] {
position: static;
margin-top: var(--rozie-combobox-list-gap, 0.25rem);
border: none;
border-radius: 0;
box-shadow: none;
}
`;
/**
* The selected option's value (two-way `r-model`). As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a combobox **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). `null` when nothing is selected.
* @example
* <Combobox r-model:value="country" :options="countries" />
*/
@property({ type: Object, attribute: 'value' }) _value_attr: unknown = null;
private _valueControllable = createLitControllableProperty<unknown>({ host: this, eventName: 'value-change', defaultValue: null, initialControlledValue: undefined });
/**
* The option list — `[{ value, label, disabled? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, and an optional `disabled` flag makes an option non-selectable.
*/
@property({ type: Array }) options: any[] = [];
/**
* Placeholder text shown in the input while it is empty.
*/
@property({ type: String, reflect: true }) placeholder: string = '';
/**
* Disable the control — the input becomes non-interactive and the popup cannot be opened. Also sets the Angular `ControlValueAccessor` disabled state.
*/
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
/**
* Opt **out** of built-in client filtering (async / server-side mode): render `options` exactly as supplied and rely on the `search` event to refetch. By default the component filters `options` by `label`, case-insensitively, against the typed query.
*/
@property({ type: Boolean, reflect: true }) disableFilter: boolean = false;
/**
* Accessible name for the input (`aria-label`), used when there is no visible `<label for>` pointing at it. Provide this (or an external label) so the combobox is announced.
*/
@property({ type: String, reflect: true }) ariaLabel: string | null = null;
/**
* Id base for the listbox and option elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`. Set a **distinct** value per instance when more than one combobox shares a page. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
@property({ type: String, reflect: true }) idBase: string = 'rozie-combobox';
/**
* Render the results list in normal flow (static) rather than as an absolutely-positioned popup. Use when embedding the combobox 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;
/**
* Close the popup after a selection commits. Defaults `true` (standard autocomplete behavior); set to `false` to keep the popup open after a selection — e.g. when the combobox is embedded in a multi-action surface like a command palette.
*/
@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;
/**
* Opt-in vertical **option windowing** for long lists. When `true`, only the visible slice of options renders inside a bounded scrolling popup (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default `false` is byte-identical to a non-windowed combobox. 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 popup scroll container when `virtual` is on (e.g. `'320px'`). Mirrored to the `--rozie-combobox-list-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 _query = signal('');
private _isOpen = signal(false);
private _activeIndex = signal(-1);
private _rows = signal<any[]>([]);
private _windowVer = signal(0);
private _editVer = signal(0);
@query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;
@query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
@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 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._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.value)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
this.syncQueryToValue();
})(); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => (this.options ? this.options.length : 0) + '|' + this._query.value)(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
this.syncRows();
if (this.virtual && this.virtualizer) {
this.virtualizer.setOptions(this.virtualizerOptions());
this.virtualizer._willUpdate();
this._windowVer.value = this._windowVer.value + 1;
this.scheduleRemeasure();
}
})(); }); }));
this.syncQueryToValue();
this.syncRows();
// ── Windowing: construct the virtualizer (ONLY when virtual) ──────────────
// The windowed popup stays mounted whenever virtual (r-if="$props.virtual"); it is only
// hidden via display:none when closed (CR-01), so the .rozie-combobox-list scroll
// container already exists here for the virtualizer to attach to.
// ── Windowing: construct the virtualizer (ONLY when virtual) ──────────────
// The windowed popup stays mounted whenever virtual (r-if="$props.virtual"); it is only
// hidden via display:none when closed (CR-01), so the .rozie-combobox-list scroll
// container already exists here for the virtualizer to attach to.
if (this.virtual) {
// Capture the scroll container 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, leaving the virtualizer with no scroll element.
this.gridScrollEl = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rozie-combobox-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.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-combobox": true, 'rozie-combobox--open': this._isOpen.value, 'rozie-combobox--disabled': this.disabled, 'rozie-combobox--inline': this.inline }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="__rozieRoot" data-rozie-s-9546115a>
<input class="rozie-combobox-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded=${!!this._isOpen.value} aria-controls=${rozieAttr(this.listId())} aria-activedescendant=${rozieAttr(this.activeId())} aria-label=${this.ariaLabel} .value=${this._query.value} placeholder=${this.placeholder} ?disabled=${!!this.disabled} autocomplete="off" @input=${($event: Event) => { this.onInput($event); }} @focus=${($event: Event) => { this.onFocus($event); }} @blur=${($event: Event) => { this.onBlur(); }} @keydown=${($event: Event) => { this.onKeydown($event); }} data-rozie-ref="inputEl" data-rozie-s-9546115a />
${this._isOpen.value && !this.virtual ? html`<ul class="rozie-combobox-list" id=${rozieAttr(this.listId())} role="listbox" data-rozie-s-9546115a>
${repeat<any>(this.filteredOptions(), (opt, _idx) => opt.value, (opt, _idx) => html`<li class="${Object.entries({ "rozie-combobox-option": true, 'rozie-combobox-option--active': opt._i === this._activeIndex.value, 'rozie-combobox-option--selected': opt.value === this.value, 'rozie-combobox-option--disabled': opt.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieAttr(opt.value)} id=${rozieAttr(this.optId(opt._i))} role="option" aria-selected=${opt.value === this.value} aria-disabled=${!!opt.disabled} @mousedown=${($event: MouseEvent) => { $event.preventDefault(); this.selectOption(opt); }} @mouseenter=${($event: Event) => { this._activeIndex.value = opt._i; }} data-rozie-s-9546115a>
${this.option !== undefined ? this.option({option: opt.option, index: opt._i, active: opt._i === this._activeIndex.value, selected: opt.value === this.value, disabled: opt.disabled}) : html`<slot name="option" data-rozie-params=${(() => { try { return JSON.stringify({option: opt.option, index: opt._i, active: opt._i === this._activeIndex.value, selected: opt.value === this.value, disabled: opt.disabled}); } catch { return '{}'; } })()}>${rozieDisplay(opt.label)}</slot>`}
</li>`)}
${this.filteredOptions().length === 0 ? html`<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>
${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 results</slot>`}
</li>` : nothing}</ul>` : nothing}${this.virtual ? html`<ul class="rozie-combobox-list rozie-combobox-list--virtual" id=${rozieAttr(this.listId())} role="listbox" style=${rozieStyle((this._isOpen.value ? '' : 'display:none;') + (this.maxHeight ? 'height:' + this.maxHeight + ';max-height:' + this.maxHeight + ';overflow-y:auto;--rozie-combobox-list-max-height:' + this.maxHeight : 'overflow-y:auto'))} data-rozie-s-9546115a>
<li class="rozie-combobox-spacer" aria-hidden="true" style=${rozieStyle('height:' + this.padTop() + 'px')} data-rozie-s-9546115a></li>
${repeat<any>(this.windowedRows(), (wr, _idx) => wr.row.id, (wr, _idx) => html`<li class="${Object.entries({ "rozie-combobox-option": true, 'rozie-combobox-option--active': wr.vi.index === this._activeIndex.value, 'rozie-combobox-option--selected': wr.row.value === this.value, 'rozie-combobox-option--disabled': wr.row.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieAttr(wr.row.id)} id=${rozieAttr(this.optId(wr.vi.index))} data-index=${rozieAttr(wr.vi.index)} role="option" aria-selected=${wr.row.value === this.value} aria-disabled=${!!wr.row.disabled} @mousedown=${($event: MouseEvent) => { $event.preventDefault(); this.selectOption(wr.row); }} @mouseenter=${($event: Event) => { this._activeIndex.value = wr.vi.index; }} data-rozie-s-9546115a>
${this.option !== undefined ? this.option({option: wr.row.option, index: wr.vi.index, active: wr.vi.index === this._activeIndex.value, selected: wr.row.value === this.value, disabled: wr.row.disabled}) : html`<slot name="option" data-rozie-params=${(() => { try { return JSON.stringify({option: wr.row.option, index: wr.vi.index, active: wr.vi.index === this._activeIndex.value, selected: wr.row.value === this.value, disabled: wr.row.disabled}); } catch { return '{}'; } })()}>${rozieDisplay(wr.row.label)}</slot>`}
</li>`)}
<li class="rozie-combobox-spacer" aria-hidden="true" style=${rozieStyle('height:' + this.padBottom() + 'px')} data-rozie-s-9546115a></li>
${this.windowSource().length === 0 ? html`<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>
${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 results</slot>`}
</li>` : nothing}</ul>` : nothing}</div>
`;
}
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;
};
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;
foCache = {
optsRef: null,
q: null,
df: null,
val: null,
hasVal: false
};
filteredOptions = () => {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
// stale/blank window.
const opts = Array.isArray(this.options) ? this.options : [];
const df = !!this.disableFilter;
const q = String(this._query.value == null ? '' : this._query.value);
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
if (this.foCache.hasVal && this.foCache.optsRef === opts && this.foCache.q === q && this.foCache.df === df) return this.foCache.val;
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(this.labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
const val = list.map((o: any, i: any) => ({
value: this.valueOf$local(o),
label: this.labelOf(o),
disabled: this.disabledOf(o),
_i: i,
id: this.valueOf$local(o),
option: o
}));
this.foCache.optsRef = opts;
this.foCache.q = q;
this.foCache.df = df;
this.foCache.val = val;
this.foCache.hasVal = true;
return val;
};
windowSource = () => this.filteredOptions();
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-combobox-option[data-index]');
for (const el of els as any) this.virtualizer.measureElement(el);
};
scrollActiveIntoView = () => {
if (!this.virtual || !this.virtualizer || this._activeIndex.value < 0) return;
// 'center' (not 'auto'): keep the active option well inside the rendered slice — '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();
};
optId = (i: any) => this.idBase + '-opt-' + i;
listId = () => this.idBase + '-list';
activeId = () => {
const list = this.filteredOptions();
if (this._isOpen.value && this._activeIndex.value >= 0 && list[this._activeIndex.value]) return this.optId(this._activeIndex.value);
return null;
};
nextEnabled = (list: any, from: any, dir: any) => {
let i = from;
for (let step = 0; step < list.length; step++) {
i = i + dir;
if (i < 0) i = 0;
if (i >= list.length) i = list.length - 1;
if (list[i] && !list[i].disabled) return i;
if (dir < 0 && i === 0 || dir > 0 && i === list.length - 1) break;
}
return from;
};
selectOption = (opt: any) => {
if (!opt || opt.disabled) return;
this._valueControllable.write(opt.value);
this._query.value = String(opt.label);
if (this.closeOnSelect) this._isOpen.value = false;
this._activeIndex.value = -1;
this.dispatchEvent(new CustomEvent("change", {
detail: {
value: opt.value,
option: opt.option
},
bubbles: true,
composed: true
}));
};
syncQueryToValue = () => {
const opts = Array.isArray(this.options) ? this.options : [];
const opt = opts.find((o: any) => o.value === this.value);
this._query.value = opt ? String(opt.label) : '';
};
onInput = (e: any) => {
const q = e && e.target ? e.target.value : '';
this._query.value = q;
this._isOpen.value = true;
this._activeIndex.value = 0;
this.dispatchEvent(new CustomEvent("search", {
detail: {
query: q
},
bubbles: true,
composed: true
}));
};
onFocus = (e: any) => {
this._isOpen.value = true;
if (e && e.target && e.target.select) e.target.select();
};
onBlur = () => {
this._isOpen.value = false;
};
onKeydown = (e: any) => {
const key = e ? e.key : '';
const list = this.filteredOptions();
// Capture the reactive reads into locals BEFORE any write so React never binds
// a pre-write value (ROZ138; the read-then-write-same-key idiom). Each branch
// is mutually exclusive, but a flow-insensitive analysis can't see that.
const wasOpen = this._isOpen.value;
const ai = this._activeIndex.value;
if (key === 'ArrowDown') {
if (e) e.preventDefault();
if (!wasOpen) {
this._isOpen.value = true;
this._activeIndex.value = 0;
return;
}
this._activeIndex.value = this.nextEnabled(list, ai, 1);
} else if (key === 'ArrowUp') {
if (e) e.preventDefault();
if (!wasOpen) {
this._isOpen.value = true;
return;
}
this._activeIndex.value = this.nextEnabled(list, ai, -1);
} else if (key === 'Enter') {
if (wasOpen && ai >= 0 && list[ai]) {
if (e) e.preventDefault();
this.selectOption(list[ai]);
}
} else if (key === 'Escape') {
if (wasOpen) {
if (e) e.preventDefault();
this._isOpen.value = false;
}
} else if (key === 'Home') {
if (wasOpen) {
if (e) e.preventDefault();
this._activeIndex.value = this.nextEnabled(list, -1, 1);
}
} else if (key === 'End') {
if (wasOpen) {
if (e) e.preventDefault();
this._activeIndex.value = this.nextEnabled(list, list.length, -1);
}
}
// Keep the (new) active option in view when windowing — no-op when not virtual.
this.scrollActiveIntoView();
};
kickWindow = (attempts: any) => {
if (!this.virtualizer) return;
this.gridScrollEl = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rozie-combobox-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);
}
};
focus = () => this._refInputEl?.focus();
clear = () => {
this._valueControllable.write(null);
this._query.value = '';
this._activeIndex.value = -1;
this.dispatchEvent(new CustomEvent("change", {
detail: {
value: null
},
bubbles: true,
composed: true
}));
};
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>(['value', 'options', 'placeholder', 'disabled', 'disable-filter', 'disablefilter', 'aria-label', 'arialabel', 'id-base', 'idbase', 'inline', 'close-on-select', 'closeonselect', 'option-label', 'optionlabel', 'option-value', 'optionvalue', 'option-disabled', 'optiondisabled', '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 change / search events, same two-way value, same #option scoped slot, same imperative handle — all from the one source above, built on native DOM with no third-party engine behind it.
See also
- Combobox — showcase & API — install, quick start, filtering, theming, keyboard, and the full reference.
- Headless combobox / autocomplete comparison — how
@rozie-ui/comboboxstacks up against Headless UI, Radix + cmdk, downshift, vue-select, and the Angular CDK/Material autocomplete.