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. The same Combobox component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit. It's 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.
What ships for each framework
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.inputText` (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` and `seedQuery` are
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.
- `seedQuery(text)` is an IMPERATIVE-ONLY handle verb (added as a
command-palette #2 levels/restore-on-pop prerequisite): it writes
`$data.inputText` directly so both the input text AND filteredOptions() (which
reads `$data.inputText`) reflect the seeded text. It is deliberately NOT a
second model — combobox's sole `model: true` prop stays `value` (ROZ125: a
second model forfeits the Angular ControlValueAccessor). Additive +
render-neutral: never invoked ⇒ no default-render change.
Consumer example:
<Combobox r-model:value="$data.country" :options="countries"
placeholder="Search…" ariaLabel="Country" @change="onPick" />
-->
<rozie name="Combobox" vendorable="true">
<types>
// The typed public surface (typed-surface P1; always TypeScript). `value` /
// `option` stay `any`: options are consumer-shaped objects (or primitives) the
// component never inspects beyond the label/value/disabled resolvers.
/** `search` payload — the current input text. */
export interface ComboboxSearchPayload { query: string }
/** `change` payload — `option` is the raw source option (`null` for a clear or a free-text commit); `text` is set ONLY on free-text commits. */
export interface ComboboxChangePayload { value: any; option: any; selected: boolean; text?: string }
/** `create` payload — the (untrimmed) query the user asked to create. */
export interface ComboboxCreatePayload { query: string }
/** An entry of the `groups` prop. */
export interface ComboboxGroup { id: string; label: string }
/** `chip` slot params — `remove()` removes the chip and refocuses the input. */
export interface ComboboxChipSlotCtx { option: any; remove: () => void; index: number }
/** `option` slot params. */
export interface ComboboxOptionSlotCtx { option: any; index: number; active: boolean; selected: boolean; disabled: boolean }
/** `empty` / `create` slot params. */
export interface ComboboxQuerySlotCtx { query: string }
/** `groupHeading` slot params. */
export interface ComboboxGroupHeadingSlotCtx { group: ComboboxGroup }
/** `groupMore` slot params. */
export interface ComboboxGroupMoreSlotCtx { group: ComboboxGroup | null; hidden: number; expand: () => void }
</types>
<!--
Phase 86 (D-01): the SECOND Option A published-leaf composition chain — the
first was Phase 75's data-table→popover / command-palette→combobox pair;
this makes command-palette → combobox → popover a proven TWO-level chain.
The STABLE package-style specifier ending in `.rozie` resolves, via
@rozie/core's resolveManifestProducer, to the PUBLISHED per-target
`@rozie-ui/popover-<target>` package's compiled manifest at compile time —
the IDENTICAL mechanism data-table uses for its own `<components>{ Popover:
... }` entry. There is NO vendored sibling and NO in-memory remap. Do NOT
hand-write the relative `./Popover.rozie` — that would be Option B (source
vendoring), the stale approach CommandPalette.rozie's own header comment
still describes (predating Phase 75); this is Option A. Popover composes
around ALL FOUR render branches below (plain / grouped / grouped+capped /
windowed — plan 86-03 finished bringing the last three onto this path); the
composed `@rozie-ui/popover-<target>` package is a runtime peerDependency of
each combobox leaf and is NOT publicly re-exported — consumers use
`@rozie-ui/popover-<target>` directly.
-->
<components>{ Popover: '@rozie-ui/popover/Popover.rozie' }</components>
<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?, group? }]`.
options: {
type: Array,
default: () => [],
docs: {
description:
'The option list — `[{ value, label, disabled?, group? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, an optional `disabled` flag makes an option non-selectable, and an optional `group` string buckets the option under a matching entry of the `groups` prop (or a first-appearance fallback section) when grouping is active.',
},
},
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). '' (the default) → a unique per-instance base generated after mount
// (idRoot() below), so two default-configured comboboxes never share ids.
// Named `idBase` (not `id`) to avoid shadowing HTMLElement.id on Lit.
idBase: {
type: String,
default: '',
docs: {
description:
'Id base for the listbox, option and popup elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`, the listbox id is `idBase + "-list"`. Leave it empty (the default) and each instance generates a unique id base after mount (`rozie-combobox-<n>`); set it when you need stable, predictable ids. 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 is a SENTINEL `null` ("unset"),
// NOT a literal `true` — Phase 86 (D-15/R1) needs `effectiveCloseOnSelect()`
// (below) to distinguish "the consumer never set this" from "the consumer
// explicitly passed `true`", and every ×6 target merges an unset prop as
// `_props.closeOnSelect ?? <default>` at the emission boundary BEFORE any
// script code runs — a literal `true` default is therefore indistinguishable
// from an explicit `true` once the script sees it (confirmed against the
// existing `ariaLabel` prop, which already uses this identical `default:
// null` -> `(T) | null` sentinel shape). `effectiveCloseOnSelect()` resolves
// the sentinel to `true` in single-select (today's behavior, unchanged) and
// to `false` in `multiple` mode (D-15 — closing after every chip pick would
// make multi-select unusable); an explicit `true`/`false` from the consumer
// always wins in either mode. Route every read through that helper — never
// read `$props.closeOnSelect` directly.
closeOnSelect: {
type: Boolean,
default: null,
docs: {
description:
'Close the popup after a selection commits. Unset (default) resolves through `effectiveCloseOnSelect()`: `true` in single-select (today\'s default behavior) and `false` in `multiple` mode, where closing after every chip pick would make multi-select unusable. Pass an explicit `true` or `false` to override in either mode.',
},
},
// ── Multi-select via the widened sole `value` model (Phase 86 R1) ────────
// Ported from the identical shipped shape on `@rozie-ui/listbox` (its own
// `multiple` prop + the shared listCore.rzts `select()`/`isSelected()`/
// `clear()` algorithm) rather than invented fresh. Does NOT add a second
// `model: true` prop — ROZ125 is hard: a second model would suppress the
// Angular `ControlValueAccessor`, which is exactly why `seedQuery` is an
// imperative handle verb today instead of a second model. Default `false`
// is byte-identical to today's single-select behavior.
multiple: {
type: Boolean,
default: false,
docs: {
description:
'`value` widens to hold an **array** of selected values and remains the sole `model: true` prop, so the Angular `ControlValueAccessor` is preserved (a second model would forfeit it — `ROZ125`). Re-selecting an already-selected option toggles it off. Default `false` is byte-identical to single-select.',
},
},
// ── Creatable mode: consumer-owned `create` event (Phase 86 R3) ──────────
// When the user commits text matching no option (case-insensitive,
// trimmed, EXACT label equality — no Unicode normalization applied),
// combobox emits `create` with the query and writes NOTHING to `value` —
// the consumer adds the option to `options` and updates the model itself;
// combobox never guesses the consumer's option shape. Composes with
// `multiple`. Turning this on replaces the `#empty` fill with the
// `#create` row whenever the query is creatable (non-empty, no exact
// match) — `#empty` still renders for an empty or whitespace-only query.
// Default `false` is byte-identical to today: no create row ever renders,
// `create` never fires.
creatable: {
type: Boolean,
default: false,
docs: {
description:
'When the user commits text matching no option (case-insensitive, trimmed, exact label equality — no Unicode normalization applied), combobox emits `create` with the query and writes NOTHING to `value` — the consumer adds the option to `options` and updates the model itself. Composes with `multiple`. Turning this on replaces the `#empty` fill with the `#create` row whenever the query is creatable (non-empty, no exact match); `#empty` still renders for an empty or whitespace-only query. Default `false` is byte-identical to today.',
},
},
// 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.",
},
},
// ── Native option grouping (combobox-native-groups) ─────────────────────
// Ordered section list `[{ id, label }]` setting group order + heading
// text. Options are partitioned by their optional `group?` string; groups
// present on options but absent here fall back to first-appearance order
// after the listed ones. Empty/absent ⇒ flat, ungrouped rendering
// (default) — byte-identical to a combobox with no grouping concept.
groups: {
type: Array,
default: () => [],
docs: {
description:
'Ordered section list `[{ id, label }]` setting group order + heading text. Options are partitioned by their optional `group?` string; groups present on options but absent here fall back to first-appearance order after the listed ones. Empty/absent ⇒ flat, ungrouped rendering (default).',
},
},
// ── Per-group result cap with expand-in-place "+N more" (combobox-group-cap) ──
// Cap each native section group to its first `groupCap` results, adding a
// keyboard-reachable "+N more" row that expands that group IN PLACE when
// activated. 0/absent = uncapped (default). Only
// applies to the non-virtual grouped render (`groups` non-empty); ignored
// when `virtual` is on.
groupCap: {
type: Number,
default: 0,
docs: {
description:
'Cap each native section group to its first `groupCap` results, adding a keyboard-reachable \'+N more\' row that expands that group IN PLACE when activated. `0`/absent = uncapped (default). Only applies to the non-virtual grouped render (`groups` non-empty); ignored when `virtual` is on.',
},
},
// ── Floating positioning via the composed @rozie-ui/popover leaf (Phase 86 R2/R4) ──
// Forwarded straight through to the single Popover wrapper element around all
// four render branches (plain / grouped / grouped+capped / windowed — plan
// 86-03). Ignored when `inline` is set — `inline` forwards `disablePositioning`
// to popover instead (D-09), so the popup never floats.
placement: {
type: String,
default: 'bottom-start',
docs: {
description:
'Floating UI placement of the popup relative to the control, forwarded to the composed `@rozie-ui/popover` leaf — one of `top`/`right`/`bottom`/`left`, each optionally suffixed `-start`/`-end`. Default `"bottom-start"` matches the pre-Phase-86 static popup alignment (flush with the control\'s left edge). Ignored when `inline` is set.',
},
},
offset: {
type: Number,
default: 4,
docs: {
description:
'Gap in pixels between the control and the popup, forwarded to the composed `@rozie-ui/popover` leaf. Default `4` preserves the pre-Phase-86 resting gap (`--rozie-combobox-list-gap`). Ignored when `inline` is set.',
},
},
disableFlip: {
type: Boolean,
default: false,
docs: {
description:
"Disable the popup's Floating UI `flip` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup flips above the control when it would overflow the viewport below; set this to keep it pinned to `placement` regardless. Ignored when `inline` is set.",
},
},
disableShift: {
type: Boolean,
default: false,
docs: {
description:
"Disable the popup's Floating UI `shift` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup shifts to stay within the viewport; set this to keep it strictly aligned to the control. Ignored when `inline` is set.",
},
},
// ── Token-input surface (release-0.8.0, COMBOBOX-SPEC items 1-8) ─────────
// Every prop below defaults OFF: unset ⇒ byte-identical behavior + render.
// Fill the container: root display:block + width:100%; the control (chips +
// input) stretches via a `cqw` width against the root (the anchor wrapper is
// popover-owned + shrink-to-fit, so a plain percentage would be circular), and
// the width-matched popup follows.
block: {
type: Boolean,
default: false,
docs: {
description:
'Fill the container: the root becomes `display: block; width: 100%`, the control (chips + input) stretches to that width, and the width-matched popup follows. Adds the `rozie-combobox--block` modifier class on the root. Default `false` keeps the fixed `--rozie-combobox-width` sizing.',
},
},
// 'stacked' (default): the chip rail renders above the input. 'inline': chips
// and the input share ONE wrapping row (the Tags layout). multiple-only.
chipLayout: {
type: String,
default: 'stacked',
docs: {
description:
"Chip rail layout under `multiple`: `'stacked'` (default) renders the chips above the input; `'inline'` puts the chips and the input on ONE wrapping row (the Tags layout), with the input taking the remaining width (`flex: 1`, never narrower than `--rozie-combobox-inline-input-min-width`). Only meaningful with `multiple`.",
},
},
// Focus does NOT open the list; typing and ArrowDown/ArrowUp still do.
disableOpenOnFocus: {
type: Boolean,
default: false,
docs: {
description:
'Do not open the list when the input gains focus. Typing and ArrowDown / ArrowUp still open it. Default `false` opens on focus.',
},
},
// When there is nothing to render (no option rows AND no create row), the popup
// is not shown, aria-expanded is false, and Escape is not consumed.
hideEmpty: {
type: Boolean,
default: false,
docs: {
description:
'Show nothing instead of the empty state: when there are no option rows and no create row, the popup is not shown, the input reports `aria-expanded="false"`, and Escape is left to the host (not `preventDefault`ed). This is the supported way to render no popup at all; filling the `empty` slot with nothing still renders the fallback on most targets.',
},
},
// Keys that commit the TYPED text as a value (never the highlighted option),
// multiple-only. Character entries also split pasted text. 'Enter'/'Tab' are
// allowed: Enter then commits typed text only when no option is highlighted.
delimiters: {
type: Array,
default: () => [],
docs: {
description:
"Keys that commit the **typed text** as a value (matched against the key event's `key`), under `multiple` only — a delimiter never picks the highlighted option. Character entries (e.g. `[',', ';']`) also split pasted text: a paste containing a delimiter is split on them, every non-empty trimmed part that `validate` accepts is committed, and the rejected parts are inserted at the caret (replacing the selection) like an ordinary paste, so text typed before the paste is kept. Use `splitPaste` to replace this split. `'Enter'` and `'Tab'` are allowed; Enter then commits the typed text only when no option is highlighted. A non-empty list (or `validate`, `splitPaste` or `commitOnBlur`) turns on free-text commits, so Enter with no highlighted option commits the typed text too. Default `[]` (off).",
example: '<Combobox multiple r-model:value="to" :options="contacts" :delimiters="delims" />',
},
},
// Gate (and optionally normalise) every free-text commit, Tags-style: a string
// return is the value stored; a falsy return rejects; `true` keeps the text.
// Enabling it alone also turns free-text commits on.
validate: {
type: Function,
default: null,
docs: {
description:
'Free-text gate and normaliser, `(text: string) => string | boolean | null | undefined`, under `multiple` only. Called with the trimmed typed (or pasted) text before every free-text commit. Return the **string to store** (e.g. the bare address out of `Sam Roe <sam@x.test>`), `true` to store the text as typed, or a falsy value (`false` / `null` / `\'\'`) to reject it — rejected text stays in the input. The same shape as Tags\' `validate`. Setting it also turns on free-text commits (Enter with no highlighted option commits the typed text). A free-text commit appends the stored string to `value` (skipped when already present), clears the input, and emits `change` with `option: null` and the stored string as `text`.',
example: '<Combobox multiple r-model:value="to" :options="contacts" :validate="toAddress" />',
},
},
// Replace the built-in delimiter split of pasted text (free-text mode). Returns
// the parts to commit, or null to leave the paste to the browser.
splitPaste: {
type: Function,
default: null,
docs: {
description:
"Replaces the built-in paste split, `(text: string) => string[] | null`, under `multiple` only. Called with the clipboard text on every paste. Return the parts to commit — each is trimmed and passed through `validate`; accepted parts are committed and the rejected ones are inserted at the caret — or `null` to leave the paste to the browser untouched. Use it for syntax the delimiter split cannot know about, e.g. a quoted display name containing a comma (`\"Roe, Sam\" <sam@x.test>`). Setting it also turns on free-text commits.",
example: '<Combobox multiple r-model:value="to" :options="contacts" :validate="toAddress" :split-paste="splitAddresses" />',
},
},
// Commit the typed text when the input loses focus (through validate).
commitOnBlur: {
type: Boolean,
default: false,
docs: {
description:
'Commit the typed text when the input loses focus, under `multiple` only, through `validate` like every other free-text commit: accepted text is committed and the input cleared, rejected text stays. A blur into a pinned host sub-surface (`pinOpen(true)`) does not commit. Setting it also turns on free-text commits. Default `false`.',
},
},
// Tab picks the highlighted option when the popup is visible; preventDefault
// ONLY when it picked (otherwise Tab moves focus normally).
selectOnTab: {
type: Boolean,
default: false,
docs: {
description:
'Tab picks the highlighted option while the popup is visible and an option is highlighted, keeping focus in the input. When nothing is picked, Tab moves focus normally. Default `false` (Tab always moves focus).',
},
},
}
</props>
<emits>
{
search: { payload: 'ComboboxSearchPayload', docs: { description: 'Fired whenever the input text changes: on every keystroke with the current text, after a paste that Combobox handles itself (with the resulting text), and with `{ query: \'\' }` whenever Combobox clears the text itself (a pick under `multiple`, a free-text commit — including one of a value that is already selected — or `clear()`). Pair with `disableFilter` for async / server-side filtering: refetch on `search` and feed the results back through `options`. `query()` on the handle reads the current text.' } },
change: { payload: 'ComboboxChangePayload', docs: { description: 'Fired after a selection commits (pick, chip removal or toggle-off, `clear()`, or a free-text commit). `value` is the new model value, `option` the raw source option (`null` for a clear or a free-text commit), `selected` whether the value was added (`false` for a removal or a clear), and `text` the committed text, set only on free-text commits (`delimiters` / `validate`).' } },
create: { payload: 'ComboboxCreatePayload', docs: { description: 'Fired (with `creatable`) when the user commits a query that matches no option. Nothing is written to `value`: add the option to `options` and update the model yourself.' } },
}
</emits>
<data>
{
// The text in the input (read through the `query()` handle method; named
// `inputText`, not `query`, so the exposed `query` verb has a free name).
inputText: '',
// 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,
// Per-group expanded state for the groupCap "+N more" affordance (combobox-
// group-cap). A map of gkey(gid) -> true for groups the user has expanded.
// Replaced immutably so React re-renders; reset by the options/query $watch.
expandedGroups: {},
// Double-commit latch for creatable mode (Phase 86 R3, D-17/D-20): the
// normalized (trimmed + lower-cased) query most recently committed via a
// `create` emit, or `null`. A second commit of the SAME normalized query
// (e.g. two rapid Enter presses, or the async round-trip window before the
// consumer's `options` update lands) is a no-op — `create` fires exactly
// once per distinct query. Cleared on every input change (onInput below).
createdQuery: null,
// Whether the popup is pinned open by a host sub-surface (Phase 86-07
// regression fix, D-24): pinOpen(v) writes here. Promoted from a plain
// (non-reactive) `let` to `<data>` because it is now ALSO forwarded to the
// composed Popover's `:disable-dismiss` prop below — Popover's OWN
// document-level Escape/click-outside dismissal (D-07) writes `isOpen`
// directly and never consulted the old onBlur-only guard, so a host
// sub-surface's own click (e.g. command-palette's action-menu flyout, which
// lives outside Popover's anchorEl/floatingEl) was judged "outside" and
// silently closed the list out from under the pin. onBlur() below still
// reads it too, for the ordinary blur-into-flyout path.
pinned: false,
// The generated id base (idRoot()), set once in $onMount when `idBase` is empty.
autoId: '',
}
</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'
// ---- native option grouping (combobox-native-groups: src/internal/groupOptions.ts) ----
// The PURE stable-partition helper is a RUNTIME import (unlike listCore/windowing
// above, it is NOT a compile-time `.rzts` partial that dissolves at compile) —
// codegen's `copyInternal` vendors it verbatim into each leaf at
// `./internal/groupOptions`, mirroring command-palette's `scoreCommands.ts`.
import { groupOptions } from './internal/groupOptions'
// 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
// Scroll-end pin state (see recordScrollEnd()): whether the USER left the view at the end,
// the option count at that moment, and the last scrollTop already accounted for.
let scrollEndPinned: boolean = false
let scrollEndPinnedCount: number = -1
let scrollEndPinnedTop: number = -1
// Non-reactive per-instance flag (Phase 86 R2, plan 86-03, Solid-only): true for
// the duration of an onFocus-triggered open transition (set before the isOpen
// write, cleared in the deferred microtask after). Lets onBlur distinguish a
// blur caused by Solid recreating the anchor's DOM mid-open (skip closing) from
// a genuine user-initiated blur (close normally). See onFocus/onBlur below.
let openingInProgress = false
// Non-reactive per-instance flag (combobox-virtual-reactivity phase): set true once
// $onMount has run; read by windowedView() below so the blank-frame fallback (D-4) only
// fires on a genuine RUNTIME flip — a virtual:true-at-mount (never-flipped) consumer's
// first paint stays byte-stable (windowedRows()'s own pre-mount `[]` still applies before
// didMount flips true). Mirrors the same write-in-$onMount/read-elsewhere holder class.
let didMount = 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.
//
// Quick 260717-8zb dogfood: re-expressed on the `$memo(fn, keyFn)` primitive.
// `$memo` lowers (core, shared across all 6 targets) to a member-mutated
// fresh-object cache const + a wrapper function — EXACTLY this foCache shape,
// generalized. On React the emitted cache const is stabilized to
// `useMemo(() => ({…}), [])` by the EXISTING collectMutatedInstanceBinders/
// tryWrapMutatedInstanceUseMemo machinery (feedback_react_const_mutinstance_
// not_stabilized) — no per-target $memo code. On the 5 setup-once targets the
// top-level consts persist for the instance lifetime naturally.
//
// keyFn is the SUBSCRIBE-FIRST half (fine-grained Solid <For> / Svelte
// {#each}): it reads ALL FOUR reactive inputs UNCONDITIONALLY — $data.inputText
// even when disableFilter is true (mirrors windowing.rzts windowedRows
// void-touch discipline) and $props.groups even when $props.virtual (so a
// groups change while windowed still invalidates the cache once virtual
// toggles off) — evaluated BEFORE $memo's cache-hit check, so the r-for
// accessor subscribes to them on every eval. 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 / groups-ref) change.
//
// fn is the MISS path (unchanged from the hand-rolled foCache): run the
// filter, then (native option grouping, combobox-native-groups) a
// NON-VIRTUAL-ONLY stable re-partition into group-visual order, then map to
// wrapper rows.
const filteredOptions = $memo(
() => {
const opts = Array.isArray($props.options) ? $props.options : []
const df = !!$props.disableFilter
const q = String($data.inputText == null ? '' : $data.inputText)
const groupsProp = $props.groups
let list = opts
if (!df) {
const ql = q.toLowerCase()
if (ql) list = opts.filter((o) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1)
}
// Gated to !$props.virtual (groups×virtual is deferred/unsupported per design) AND to
// $props.groups being a NON-EMPTY array — an explicit author opt-in. This is deliberately
// NOT just "!$props.virtual" (groupOptions() would otherwise also fire whenever any raw
// option happens to carry a `.group` field, even with `groups` absent — a real collision
// discovered against command-palette's CommandItem.group, which is a PRE-EXISTING,
// unrelated per-row-badge field, not an opt-in to combobox's native grouping. The design's
// "Empty/absent `groups` ⇒ today's flat behavior, byte-identical" contract is about the
// `groups` PROP only — never inferred from incidental option shape.
if (!$props.virtual && Array.isArray(groupsProp) && groupsProp.length > 0) {
const partition = groupOptions(list, groupsProp, (o) => (o && o.group != null ? String(o.group) : null))
list = partition.ordered
}
// `_i` is assigned over the (now group-ordered) list, so the flat keyboard model
// (activeIndex/aria-activedescendant/nextEnabled) walks visual order unchanged.
// `group` carries the wrapper's normalized group id for groupBlocks() below.
return list.map((o, i) => ({ value: valueOf(o), label: labelOf(o), disabled: disabledOf(o), _i: i, id: valueOf(o), option: o, group: (o && o.group != null ? String(o.group) : null) }))
},
() => {
const opts = Array.isArray($props.options) ? $props.options : []
const df = !!$props.disableFilter
const q = String($data.inputText == null ? '' : $data.inputText)
const groupsProp = $props.groups
return [opts, q, df, groupsProp]
},
)
// 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()
// windowedView() (combobox-virtual-reactivity, VIRT-FALLBACK): the combobox-side
// blank-frame fallback for the mid-flip frame. While `virtual` is on but the virtualizer
// has not yet (re)attached (didMount-gated, so the never-flipped virtual:true-at-mount
// first paint is untouched — windowedRows()'s own pre-mount `[]` still governs it),
// render the UN-WINDOWED full windowSource() slice mapped to the `{ vi: { index }, row }`
// shape the windowed template consumes (`wr.vi.index` resolves to the wrapper's own `_i`,
// since windowSource() IS the filtered/indexed list navRows()/activeIndex already walk).
// Once the virtualizer is built, delegates to windowedRows() UNCHANGED — byte-identical
// to today's steady windowed state. Entirely combobox-side: @rozie-ui/headless-core/
// windowing.rzts is untouched, preserving data-table's B13 A==B byte-identity + its
// empty-diff regen.
const windowedView = () => {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}) — touch windowVer at the
// TOP, mirroring windowedRows()'s own subscribe-first discipline (windowing.rzts), so
// the accessor re-runs when buildVirtualizer()/kickWindow() bump windowVer once the
// virtualizer attaches — the transition OUT of this fallback and into windowedRows().
void $data.windowVer
if ($props.virtual && !virtualizer && didMount) {
return windowSource().map((row) => ({ vi: { index: row._i }, row }))
}
return windowedRows()
}
// ---- native option grouping render helpers (combobox-native-groups) ---------------
// groupBlocks(): re-partition the ALREADY group-ordered filteredOptions() wrappers into
// CONTIGUOUS runs by wrapper.group (trivial + guarantees `_i` alignment, since `ordered`
// from groupOptions() is already group-contiguous). Attaches each run's `{ id, label }`
// from $props.groups (fallback label = the group id itself). Plain function — never
// $computed (mirrors filteredOptions()'s convention). Non-virtual only (isGrouped() below
// already gates the template branch that calls this).
const groupBlocks = () => {
const wrappers = filteredOptions()
const groupsProp = Array.isArray($props.groups) ? $props.groups : []
const labelFor = (gid) => {
const found = groupsProp.find((g) => g && g.id === gid)
return found ? found.label : gid
}
const blocks = []
let lastGid
for (let i = 0; i < wrappers.length; i++) {
const w = wrappers[i]
if (i === 0 || w.group !== lastGid) {
blocks.push({ group: w.group == null ? null : { id: w.group, label: labelFor(w.group) }, items: [w] })
} else {
blocks[blocks.length - 1].items.push(w)
}
lastGid = w.group
}
return blocks
}
// isGrouped(): the grouped-vs-flat template branch selector. Grouping is active
// (non-virtual only) SOLELY when the author explicitly set a non-empty `groups` prop —
// deliberately NOT "OR any option carries a group" (a real collision discovered against
// command-palette's pre-existing CommandItem.group per-row-badge field; see the
// filteredOptions() comment above). Mirrors that same non-empty-`groups` gate exactly, so
// isGrouped() and the filteredOptions() partition never disagree about which branch is active.
const isGrouped = () => !$props.virtual && Array.isArray($props.groups) && $props.groups.length > 0
// ---- per-group result cap + expand-in-place "+N more" (combobox-group-cap) --------
// capNum(): coerce $props.groupCap to a whole, positive cap; anything else (NaN,
// negative, absent) degrades to 0 (uncapped). Plain function — never $computed.
const capNum = () => {
const n = Number($props.groupCap)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
}
// isCapped(): the capped-render branch selector. isGrouped() already gates non-
// virtual + non-empty `groups`, so the cap is automatically gated OUT of the
// virtual and ungrouped paths.
const isCapped = () => isGrouped() && capNum() > 0
// gkey(gid): normalize a group id (possibly null, for the leading ungrouped
// section) into an expandedGroups map key.
const gkey = (gid) => (gid == null ? '__ungrouped__' : String(gid))
// isExpanded(gid): whether the group has been expanded via its "+N more" row.
const isExpanded = (gid) => !!($data.expandedGroups && $data.expandedGroups[gkey(gid)])
// expandGroup(gid): replace $data.expandedGroups IMMUTABLY (load-bearing for
// React re-render — feedback_react_const_mutinstance_not_stabilized / the
// graph-writeback immutability rule).
const expandGroup = (gid) => {
$data.expandedGroups = Object.assign({}, $data.expandedGroups, { [gkey(gid)]: true })
}
// cappedBlocks(): the visible-block model for the capped render — groupBlocks()
// re-sliced to `capNum()` per group (unless expanded or non-overflowing), with a
// trailing "+N more" row appended to any still-capped block. Re-indexes `_i` as a
// running counter over the WHOLE visible+more sequence so option ids/aria-
// activedescendant stay contiguous and never disagree with navRows() below.
const cappedBlocks = () => {
const blocks = groupBlocks()
const cap = capNum()
let running = 0
const out = []
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi]
const gid = blk.group ? blk.group.id : null
const showAll = isExpanded(gid) || blk.items.length <= cap
const visibleSrc = showAll ? blk.items : blk.items.slice(0, cap)
const items = []
for (let vi = 0; vi < visibleSrc.length; vi++) {
items.push(Object.assign({}, visibleSrc[vi], { _i: running }))
running++
}
let more = null
if (!showAll) {
more = { isMore: true, group: gid, hidden: blk.items.length - cap, disabled: false, _i: running, expand: () => expandGroup(gid) }
running++
}
out.push({ group: blk.group, items, more })
}
return out
}
// ---- creatable mode (Phase 86 R3, D-17..D-20) ---------------------------
// normalizedQuery(): trimmed + lower-cased query — reuses the SAME case-fold
// filteredOptions() already applies above, but for an EXACT-EQUALITY
// comparison, never a substring search, and with NO Unicode normalization
// (R3 locked: a composition-form difference must NOT be treated as a match).
const normalizedQuery = () => String($data.inputText == null ? '' : $data.inputText).trim().toLowerCase()
// queryMatchesOption(nq): whether the (already-normalized) query is an exact,
// case-insensitive, trimmed match of some option's label.
const queryMatchesOption = (nq) => {
const opts = Array.isArray($props.options) ? $props.options : []
return opts.some((o) => String(labelOf(o)).trim().toLowerCase() === nq)
}
// isCreatableQuery(): the create-row visibility gate (also gates the `#empty`
// -> `#create` swap, D-19). `creatable` must be set, the normalized query
// must be non-empty (an empty/whitespace-only query never offers create —
// `#empty` keeps its job there), and no option's normalized label may equal
// it exactly.
const isCreatableQuery = () => {
if (!$props.creatable) return false
const nq = normalizedQuery()
if (!nq) return false
return !queryMatchesOption(nq)
}
// createRowAt(baseCount): the synthetic, non-option `role="option"` create
// row (D-17) — mirrors the `groupMore` "+N more" row shape exactly (a real
// id, arrow-reachable, commits through the SAME selectOption() dispatch
// without writing the model). Each render branch passes ITS OWN flattened
// pre-create-row row count (`baseCount`) as the running index, exactly as
// `cappedBlocks()` already re-indexes `_i` across options + the more row —
// so ids / aria-activedescendant / navRows() can never disagree.
const createRowAt = (baseCount) => ({ isCreate: true, _i: baseCount, disabled: false })
// cappedRowCount(): the total navigable row count cappedBlocks() flattens to
// (visible items + more-rows, across every block) — the running index the
// capped branch's own create row (below) must continue from. Mirrors
// cappedBlocks()'s own `running` counter without re-deriving `_i` per item.
const cappedRowCount = () => {
const blocks = cappedBlocks()
let n = 0
for (let bi = 0; bi < blocks.length; bi++) {
n += blocks[bi].items.length
if (blocks[bi].more) n++
}
return n
}
// navRows(): the SINGLE keyboard/aria source of truth. Returns the EXACT
// filteredOptions() reference when not capped and not creatable (byte-
// identical-off — untouched virtual/ungrouped keyboard path); flattens
// cappedBlocks() into visible items + more-rows, in order, when capped.
// Appends the create row, AFTER the full flattened visible(+more) sequence,
// whenever isCreatableQuery() — R3's locked "renders last, after all options
// and group sections" is a positional fact here, not a per-branch special case.
const navRows = () => {
if (!isCapped()) {
const base = filteredOptions()
if (!isCreatableQuery()) return base
return base.concat([createRowAt(base.length)])
}
const out = []
const blocks = cappedBlocks()
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi]
for (let ii = 0; ii < blk.items.length; ii++) out.push(blk.items[ii])
if (blk.more) out.push(blk.more)
}
if (isCreatableQuery()) out.push(createRowAt(out.length))
return out
}
// 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
// D-05 windowing.rzts host-contract one-liner (Phase 87 87-02). rowsWindowed() preserves
// today's EXACT truthiness (byte-behavior-identical) — it is the REQUIRED symbol
// windowing.rzts calls in place of a bare `$props.virtual` read.
//
// GAP-CLOSURE 87-16 (WR-02): the column-axis host-contract symbols (`colVirtualizer`,
// `colsWindowed()`, `columnCount()`, `columnSize()`, `forcedColumns()`) that 87-02 added
// alongside this were REMOVED here — they were dead code shipped on a mistaken premise
// about the compiler's tree-shaking BFS. Combobox imports only `{ virtualItemKey,
// virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow }`
// from windowing.rzts; none of those functions' bodies reference the column-axis symbols
// (only `columnVirtualizerOptions()`/`windowedColIndices()`/`colPadLeft()`/`colPadRight()`/
// `colIsOutsideWindow()` do, and Combobox never imports any of those), so
// `inlineScriptPartials()`'s BFS never needed them to exist. See 87-REVIEW.md WR-02 /
// 87-16-SUMMARY.md for the verification trail.
const rowsWindowed = () => !!$props.virtual
// autoMeasureOn() (Phase 87 87-07, D-18/D-20): the content-driven-estimate host-contract
// gate. Combobox never lights this branch — a permanent `false` keeps windowing.rzts's
// estimateRowSize()/refineRowEstimate() accumulator dead code here. RETAINED (unlike the
// column-axis symbols above): `virtualizerOptions()` — which Combobox DOES import and call
// — wires `estimateSize: (i) => estimateRowSize(i)`, and `estimateRowSize()` calls
// `autoMeasureOn()` as its first line. This one IS reachable through the import graph.
const autoMeasureOn = (): boolean => false
// Keep $data.rows === windowSource() so the windowing math indexes the live filtered set.
const syncRows = () => { $data.rows = windowSource() }
// SCROLL-END PIN (the data-table D-19 twin, shared shape with Listbox): keep a user who
// scrolled to the END of a variable-height list at the end while the options in view measure
// taller than their estimate. The view is judged on the DOM, and only at a move the USER
// made — a move is virtual-core's own when it still holds an unreconciled scroll adjustment
// (scrollAdjustments !== 0): its above-viewport compensation writes an ABSOLUTE scrollTop
// computed from its last-observed (stale) offset, so it pulls the view back up from the end
// and must neither clear the pin nor be mistaken for the user leaving the end. That position
// is remembered so the scroll event that later reports it is not read as a user move either.
// (Judging on virtual-core's MODEL, as the data-table host does, fails here: its total grows
// with every option measured in the ResizeObserver batch while its offset stays at the stale
// value, so the pin was cleared mid-batch — every target ended 10-126px short, measured.)
const recordScrollEnd = () => {
if (!virtualizer || !gridScrollEl || virtualizer.scrollState) return
const top: number = gridScrollEl.scrollTop
if (top === scrollEndPinnedTop) return
scrollEndPinnedTop = top
if (virtualizer.scrollAdjustments !== 0) return
// Only a list that actually overflows has an end to hold: while the window has not painted
// yet (or the list is closed), scrollHeight <= clientHeight reads as "at the end" and a pin
// recorded then would jump the freshly opened list to the bottom.
const sh = gridScrollEl.scrollHeight
const ch = gridScrollEl.clientHeight
scrollEndPinned = ch > 0 && sh - ch > 1 && sh - top - ch <= 1
scrollEndPinnedCount = windowSource().length
}
// Re-apply the pin after the framework has committed the window (called from the rAF pass):
// the real maximum is known only then. Not while a programmatic scroll (scrollToIndex) is in
// flight, and not when the option count changed since the user reached the end (a new query
// or appended options must not be auto-followed).
const keepScrollEnd = () => {
if (!scrollEndPinned || !virtualizer || !gridScrollEl || virtualizer.scrollState) return
if (windowSource().length !== scrollEndPinnedCount) return
const maxTop: number = gridScrollEl.scrollHeight - gridScrollEl.clientHeight
if (maxTop - gridScrollEl.scrollTop > 1) {
gridScrollEl.scrollTop = maxTop
scrollEndPinnedTop = gridScrollEl.scrollTop
}
}
// 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 = () => {
recordScrollEnd()
if (remeasurePending) return
remeasurePending = true
let ranMicro = false
const microPass = () => { remeasureWindow() }
// N-05 (quick 260923-rrr): key the rAF pass on the OUTCOME. React and Angular commit the
// recycled window AFTER the first rAF, so one pass measured the OLD options and the new ones
// waited for virtual-core's 150ms scrolling-ended tick — with variable-height options the late
// above-viewport adjustment then moved the whole list (measured). Re-run next frame until the
// committed options cover the virtualizer's window, bounded (the data-table host twin).
let rafAttempts = 0
const rafPass = () => {
const covered = remeasureWindow()
rafAttempts = rafAttempts + 1
if (!covered && rafAttempts < 10 && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(rafPass)
return
}
keepScrollEnd()
remeasurePending = false
}
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 true
if (virtualizer.scrollState) return true
const els = gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]')
const rendered = new Set()
for (const el of els) {
virtualizer.measureElement(el)
rendered.add(el.getAttribute('data-index'))
}
// N-05: false while the framework has not yet committed the recycled window.
const items = virtualizer.getVirtualItems()
for (let i = 0; i < items.length; i++) {
if (!rendered.has(String(items[i].index))) return false
}
return true
}
// Keep the active option visible inside the popup. When windowing, route through the
// virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window scrolls
// into view (the windowed-arrow-nav seam). When NOT windowing, resolve the active
// option element directly (a within-own-shadow query, Lit-safe) and scrollIntoView it
// with 'nearest' block alignment — a plain long list taller than the popup's
// max-height must also keep the active option visible during arrow navigation.
const scrollActiveIntoView = () => {
if (!$props.virtual && $data.isOpen && $data.activeIndex >= 0) {
const list = $el ? $el.querySelector('.rozie-combobox-list') : null
const opt = list ? list.querySelector('#' + optId($data.activeIndex)) : null
if (opt) opt.scrollIntoView({ block: 'nearest' })
return
}
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()
}
// idRoot(): the id base — the `idBase` prop, else the per-instance id generated
// in $onMount (`autoId`), else the pre-mount fallback. Generated after mount (not
// during setup) so a server render and the hydrating client agree.
const idRoot = () => $props.idBase || $data.autoId || 'rozie-combobox'
const optId = (i) => idRoot() + '-opt-' + i
const listId = () => idRoot() + '-list'
// popupVisible() (hideEmpty, COMBOBOX-SPEC item 4): whether the popup is actually
// SHOWN — open AND (unless `hideEmpty`) something to render. With `hideEmpty` an
// open popup with no option rows AND no create row counts as hidden: the list
// branches do not render, aria-expanded reports false, and Escape is left to the
// host (B4). Without `hideEmpty` this is exactly `$data.isOpen` (byte-identical-off).
const popupVisible = () => {
if (!$data.isOpen) return false
if (!$props.hideEmpty) return true
return navRows().length > 0
}
// The active option's id for aria-activedescendant (null when none).
const activeId = () => {
const list = navRows()
if (popupVisible() && $data.activeIndex >= 0 && list[$data.activeIndex]) return optId($data.activeIndex)
return null
}
// activeOption() (handle verb, COMBOBOX-SPEC item 8): the highlighted RAW source
// option, or null (nothing highlighted, the popup is hidden, or the highlighted
// row is a synthetic "+N more" / create row).
const activeOption = () => {
const list = navRows()
const ai = $data.activeIndex
if (!popupVisible() || ai < 0) return null
const row = list[ai]
if (!row || row.isMore || row.isCreate) return null
return row.option === undefined ? null : row.option
}
// 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
}
// ---- multi-select membership + effective-default helpers (Phase 86 R1) -----
// Ported from @rozie-ui/headless-core/listCore.rzts's select()/isSelected()
// algorithm (also shipped, verbatim, via @rozie-ui/listbox) — PORTED, not
// imported: combobox's own open/active/query state machine is deliberately
// host-local (see the header comment above), and listCore.rzts is also
// consumed by the release-ignored listbox family, so pulling this into the
// shared partial would put listbox's frozen leaves back in scope.
//
// selectedValues(): the current selection as a de-duplicated array, tolerant
// of a null/undefined model. De-duplicates the MODEL array itself (not just
// `options`) so a re-normalized selection never reports the same value twice
// even if the model ever ends up holding a duplicate.
const selectedValues = () => {
const cur = $props.value
const arr = Array.isArray(cur) ? cur : []
return Array.from(new Set(arr))
}
// isRowSelected(row): array membership under `multiple`, strict equality
// otherwise. Replaces every raw `opt.value === $props.value` / `wr.row.value
// === $props.value` template comparison (task 2) so all four render branches
// share exactly ONE membership check and can never disagree.
const isRowSelected = (row) => {
if (!row) return false
if ($props.multiple) return selectedValues().indexOf(row.value) !== -1
return row.value === $props.value
}
// effectiveCloseOnSelect(): resolves the `closeOnSelect` sentinel (see the
// prop's own doc comment above for why the prop's default is `null`, not a
// literal `true`). Unset ⇒ `true` in single-select (today's default,
// unchanged), `false` under `multiple`; an explicit `true`/`false` from the
// consumer always wins in either mode. Every existing `closeOnSelect` read
// routes through this helper so the four render branches cannot disagree.
const effectiveCloseOnSelect = () => {
const v = $props.closeOnSelect
if (v === true || v === false) return v
return !$props.multiple
}
// chipsInline() (chipLayout, COMBOBOX-SPEC item 2): chips + input on one
// wrapping row — only meaningful under `multiple`.
const chipsInline = () => !!$props.multiple && $props.chipLayout === 'inline'
// ---- chip rail (Phase 86 R1, plan 86-05, D-13/D-16/D-18) ---------------
// chipRows(): selectedValues() (already de-duplicated — see above) mapped to
// chip-rail display rows. Each row carries the raw source `option` when it is
// still present in `options` (mirroring how filteredOptions() attaches the raw
// option to every wrapper row), or a raw-value fallback label when the option
// has disappeared from an asynchronously swapped `options` array — the locked
// R1 concurrency edge: an orphan chip persists, labelled by its raw value,
// rather than vanishing. `value` array order IS chip display order (R1
// locked); selectedValues() already preserves it.
const chipRows = () => {
const opts = Array.isArray($props.options) ? $props.options : []
return selectedValues().map((v) => {
const found = opts.find((o) => valueOf(o) === v)
return found ? { value: v, label: labelOf(found), option: found } : { value: v, label: String(v), option: null }
})
}
// chipRemoveLabel(row): the aria-label naming what a chip's remove control removes.
const chipRemoveLabel = (row) => 'Remove ' + String(row.label)
// removeChipValue(v) is defined AFTER selectOption() below (not here) — React's
// emitter derives each `useCallback`'s static dependency array from the
// helpers its body calls, and `removeChipValue` calls `selectOption`. Declaring
// it before `selectOption`'s own `const` would put `selectOption` in
// `removeChipValue`'s deps array ahead of its OWN initializer in the SAME
// module scope — a real same-render TDZ (`ReferenceError` at runtime on
// React, TS2448 "used before its declaration" at typecheck). Source order
// here IS emission order for these plain top-level consts, so
// `removeChipValue` must textually follow `selectOption`.
// ---- 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`). `effectiveCloseOnSelect()` gates the popup close.
const selectOption = (opt) => {
if (!opt) return
if (opt.isMore) {
expandGroup(opt.group)
$data.activeIndex = opt._i
return
}
if (opt.isCreate) {
// Read locals before any write (ROZ138 idiom).
const q = $data.inputText
const nq = normalizedQuery()
// The double-commit latch (D-17/D-20): a second commit of the SAME
// normalized query — whether a rapid double gesture, or the async
// round-trip window before the consumer's `options` update lands — is a
// no-op. An empty/whitespace normalized query never emits either (the
// row should not even be reachable then, since isCreatableQuery() gates
// it, but this guard is cheap insurance against a stale reference).
if (!nq || nq === $data.createdQuery) return
$data.createdQuery = nq
$emit('create', { query: q })
// D-20: after `create` fires, local UI state behaves like a pick — the
// effective close-on-select applies, and the query clears in `multiple`
// mode (ready for the next entry) and is left alone in single mode (the
// consumer's async add flows back through the ordinary `value` watch).
// `value` itself is untouched — R3 locked.
if (effectiveCloseOnSelect()) $data.isOpen = false
if ($props.multiple) clearQuery(null)
$data.activeIndex = -1
return
}
if (opt.disabled) return
if ($props.multiple) {
// Capture whether the value was already present BEFORE the toggle — this
// local is what feeds the `selected` field on the `change` payload (D-15).
const cur = selectedValues()
const wasSelected = cur.indexOf(opt.value) !== -1
// Fresh array on every commit — in-place mutation (.push/.splice) is
// silently dropped by the React/Solid/Lit/Angular change detectors.
const next = wasSelected ? cur.filter((v) => v !== opt.value) : [...cur, opt.value]
$model.value = next
// D-14: clear the query on pick under `multiple` (not the option's label)
// so Backspace-removes-last stays reachable immediately after a pick.
// `opt.isRemoval` (set only by removeChipValue() below) skips this —
// removing a chip is not a pick, and clobbering whatever the user was
// mid-typing in the search box is a separate, unrelated data loss.
if (!opt.isRemoval) clearQuery(null)
if (effectiveCloseOnSelect()) $data.isOpen = false
$data.activeIndex = -1
$emit('change', { value: next, option: opt.option, selected: !wasSelected })
return
}
$model.value = opt.value
$data.inputText = String(opt.label)
if (effectiveCloseOnSelect()) $data.isOpen = false
$data.activeIndex = -1
// D-15: `selected` is additive and always `true` in single-select.
$emit('change', { value: opt.value, option: opt.option, selected: true })
}
// removeChipValue(v): routes chip removal through the EXACT SAME toggle path
// selectOption() uses for a re-select — a synthetic wrapper row is enough,
// since the `multiple` branch above only reads `opt.value`/`opt.option`/
// `opt.disabled`/`opt.isMore` — so removal and toggle-off can never diverge
// into different payload shapes. Declared here, after selectOption(), not
// alongside chipRows()/chipRemoveLabel() above — see the comment there.
const removeChipValue = (v) => {
const opts = Array.isArray($props.options) ? $props.options : []
const found = opts.find((o) => valueOf(o) === v)
// isRemoval: true tells selectOption()'s `multiple` branch this is a
// removal, not a pick — see the D-14 comment there.
selectOption({ value: v, option: found || null, isRemoval: true })
}
// onChipRemovePointerDown() (quick-260903-0s1, E1 audit finding): the POINTER
// half of the chip remove control's split binding. Deliberately empty —
// the `.prevent` modifier this is bound to (mousedown) is its ENTIRE payload:
// preventDefault on mousedown suppresses the native focus shift, which is
// what keeps the input focused, keeps onBlur() from firing, and therefore
// keeps the popup open (the CR-02 hazard commit `d02a145ef` closed). The
// removal deliberately does NOT live here: preventDefault on mousedown does
// NOT suppress the click that follows it, so a handler bound to BOTH events
// would remove the chip twice per pointer press. See onChipRemoveActivate()
// below for where the removal actually happens.
const onChipRemovePointerDown = () => {}
// onNativeInputChange() (release-0.8.0): the `.stop` on the input's native
// `change` is its whole payload — the native event bubbles out of the inner
// <input> on blur after an edit, and on Angular (no shadow boundary) a consumer
// `(change)` binding on <rozie-combobox> would receive that DOM Event as well as
// the component's own `change` output (the same collision popover's audit B6
// removed). Stopping it keeps `change` meaning only the component event.
const onNativeInputChange = () => {}
// onChipRemoveActivate(v) (quick-260903-0s1, E1 audit finding): the CLICK half
// of the split binding — the actual removal. `click` is the one event every
// activation path produces: a real pointer press (mousedown+click), Enter or
// Space on the focused button (native <button> behavior fires `click`, never
// `keydown`-observable-as-such), AND a screen reader's synthesized activation
// (which emits `click` with no preceding `mousedown` at all — the E1 defect
// this fixes). Binding removal to `click` alone covers all three with exactly
// one removal per activation.
//
// Keyboard/AT activation puts DOM focus ON the button, which this removal
// then unmounts — without an explicit refocus, focus would fall to
// `document.body`. Restore it using the EXACT idiom onFocus() above already
// uses (proven on all six targets): a queued microtask that refocuses
// `$refs.inputEl` only when it exists and is not already `document.activeElement`.
// That activeElement guard is what makes this a strict no-op on the pointer
// path — a pointer press never moves focus off the input in the first place
// (onChipRemovePointerDown's preventDefault sees to that), so this refocus
// never re-enters onFocus() and never re-selects the in-progress query.
// $refs is safe here for the same reason it is safe everywhere else in this
// file: this is a post-mount event handler, not module-init code.
//
// `.stop` on the template's `@click` binding (real-browser VR finding,
// quick-260903-0s1): on Solid and Svelte specifically — the two targets whose
// reactivity applies a DOM mutation SYNCHRONOUSLY, inside the very handler
// that triggered it, rather than batched to a microtask like the other four
// — removing this chip's own `<li>` mid-click detaches the click event's
// `target` from the document BEFORE the event finishes bubbling. Popover's
// own document-level `@click.outside($refs.anchorEl,$refs.floatingEl)`
// dismiss listener (Popover.rozie) then evaluates `anchorEl.contains(target)`
// against the NOW-DETACHED target, which is unconditionally `false` for any
// detached node — misreading this internal removal as an outside click and
// closing the popup. `.stop` (stopPropagation) keeps this click from ever
// reaching that document listener, exactly like the sibling `@mousedown.stop`
// pattern command-palette's own action-menu-affordance row already uses to
// keep an inner gesture from bubbling into an ancestor's own listener.
const onChipRemoveActivate = (v) => {
removeChipValue(v)
queueMicrotask(() => {
if ($refs.inputEl && document.activeElement !== $refs.inputEl) $refs.inputEl.focus()
})
}
// Reflect the externally-selected value into the input text. D-14: no-ops
// under `multiple` — there is no single label to mirror into the input once
// `value` holds an array, and the query is owned by chip-picking instead.
//
// quick-260903-0s1 (E2 audit finding): routed through the SAME valueOf()/
// labelOf() resolvers every other option read in this file uses
// (filteredOptions(), chipRows(), removeChipValue(), queryMatchesOption()) —
// this was the single site that still read the raw `.value`/`.label`
// properties directly. `optionValue`/`optionLabel` are documented public
// props, and the resolvers additionally carry the primitive-option fallback
// (`String(opt)` when `opt` has no `.label`) — bypassing them blanked the
// input on both the mount path ($onMount → syncQueryToValue()) and the
// external-value path ($watch(() => $props.value, ...) → syncQueryToValue()).
//
// The "not found" guard is on `opt` being neither `undefined` NOR `null`,
// deliberately not on truthiness: with primitive options the found entry IS
// the option, so a legitimate selection of an empty string or a zero would be
// discarded by a truthiness test and re-blank the input — reintroducing the
// bug in a new shape. `Array.prototype.find` returns `undefined` on a miss,
// so that is the correct miss test; the `null` check keeps a `null` option
// from rendering as the literal text "null".
const syncQueryToValue = () => {
if ($props.multiple) return
const opts = Array.isArray($props.options) ? $props.options : []
const opt = opts.find((o) => valueOf(o) === $props.value)
$data.inputText = opt === undefined || opt === null ? '' : String(labelOf(opt))
}
// ---- free-text commits (COMBOBOX-SPEC items 5-7, multiple only) --------
// delimiterList(): the `delimiters` prop normalized to an array.
const delimiterList = () => (Array.isArray($props.delimiters) ? $props.delimiters : [])
// splitDelimiters(): the CHARACTER delimiters (everything but 'Enter'/'Tab') —
// the paste split characters.
const splitDelimiters = () => delimiterList().filter((k) => k !== 'Enter' && k !== 'Tab')
// freeTextOn(): free-text commits are enabled under `multiple` when a delimiter
// list, a validate function, a splitPaste function or commitOnBlur is supplied.
const freeTextOn = () =>
!!$props.multiple &&
(delimiterList().length > 0 ||
typeof $props.validate === 'function' ||
typeof $props.splitPaste === 'function' ||
!!$props.commitOnBlur)
// storedText(t): the `validate` gate + normaliser (Tags' shape), for an already
// trimmed, non-empty `t`. Returns the string to store, or null when rejected:
// absent validate ⇒ t; a string return ⇒ that string ('' rejects); any other
// truthy return (`true`) ⇒ t; a falsy return ⇒ rejected.
const storedText = (t) => {
if (typeof $props.validate !== 'function') return t
const r = $props.validate(t)
if (!r) return null
return typeof r === 'string' ? r : t
}
// commitTexts(texts): append every not-yet-present text to `value` (ONE fresh
// array, ONE model write) and emit one `change` per committed text, each with the
// running array as of that commit. Texts already present are skipped silently.
const commitTexts = (texts) => {
let next = selectedValues()
const committed = []
const snapshots = []
for (let i = 0; i < texts.length; i++) {
const t = texts[i]
if (next.indexOf(t) !== -1) continue
next = next.concat([t])
committed.push(t)
snapshots.push(next)
}
if (committed.length > 0) $model.value = next
$data.activeIndex = -1
for (let i = 0; i < committed.length; i++) {
$emit('change', { value: snapshots[i], option: null, selected: true, text: committed[i] })
}
}
// syncInputText(el, text): also write the LIVE input element. Angular compares a
// `[value]` binding against its last RENDERED value: fast typing followed by a
// commit in the same frame (before change detection rendered the typed text)
// leaves query '' === last-rendered '' — no DOM write, the typed text stays.
// Writing the element directly is idempotent on every other target.
const syncInputText = (el, text) => {
if (el && typeof el.value === 'string' && el.value !== text) el.value = text
}
// setTypedText(q, el): the input text changed to `q` — by typing (onInput) or by a
// paste Combobox handled itself (insertAtCaret). Re-arms the create latch, opens
// the list, highlights the first row and emits `search`, exactly as typing does.
const setTypedText = (q, el) => {
$data.inputText = q
syncInputText(el, q)
// Any input change re-arms the double-commit latch (D-17/D-20) — a
// freshly-typed query is a new gesture, never a repeat of whatever was
// last created.
$data.createdQuery = null
$data.isOpen = true
$data.activeIndex = 0
$emit('search', { query: q })
}
// clearQuery(el): Combobox clearing the input text ITSELF (a pick under
// `multiple`, a create under `multiple`, a free-text commit, clear()). Emits
// `search` with '' so a host tracking the query through `search` never goes
// stale — a free-text commit of an already-selected value fires no `change`,
// so this is the host's only signal. No emit when the text was already empty.
// The live element is consulted too: on React a commit in the same frame as the
// last keystroke still sees the pre-keystroke `inputText` in its closure.
const clearQuery = (el) => {
const had = $data.inputText !== '' || !!(el && typeof el.value === 'string' && el.value !== '')
$data.inputText = ''
syncInputText(el, '')
if (had) $emit('search', { query: '' })
}
// insertAtCaret(el, text): insert `text` into the input at the caret, replacing
// the selection — what an ordinary paste does — and leave the caret after it.
const insertAtCaret = (el, text) => {
const cur = el && typeof el.value === 'string' ? el.value : String($data.inputText)
const start = el && typeof el.selectionStart === 'number' ? el.selectionStart : cur.length
const end = el && typeof el.selectionEnd === 'number' ? el.selectionEnd : start
const next = cur.slice(0, start) + text + cur.slice(end)
setTypedText(next, el)
const caret = start + text.length
if (el && typeof el.setSelectionRange === 'function') el.setSelectionRange(caret, caret)
}
// commitFreeText(raw, el): trim → validate (normalise) → commit + clear the input.
// Returns true when the text was handled (committed, or already present ⇒ just
// cleared); false when empty or rejected — rejected text stays in the input.
const commitFreeText = (raw, el) => {
const t = String(raw == null ? '' : raw).trim()
if (!t) return false
const stored = storedText(t)
if (stored === null) return false
clearQuery(el)
commitTexts([stored])
return true
}
// splitOnDelimiters(text): the built-in paste split — the clipboard text split on
// every CHARACTER delimiter, or null when it contains none (an ordinary paste).
const splitOnDelimiters = (text) => {
const seps = splitDelimiters()
let hasSep = false
for (let s = 0; s < seps.length; s++) {
if (text.indexOf(seps[s]) !== -1) hasSep = true
}
if (!hasSep) return null
let parts = [text]
for (let s = 0; s < seps.length; s++) {
const out = []
for (let p = 0; p < parts.length; p++) {
const pieces = String(parts[p]).split(seps[s])
for (let q = 0; q < pieces.length; q++) out.push(pieces[q])
}
parts = out
}
return parts
}
// onPaste(e) (item 6): under free-text mode the clipboard text is split — by
// `splitPaste` when supplied, else on the character delimiters — and every
// non-empty trimmed part `validate` accepts is committed (the paste is
// preventDefault-ed). The rejected parts (joined by the first delimiter) are
// inserted at the caret, replacing the selection, as an ordinary paste would be,
// so text typed before the paste is kept. A split of null (splitPaste said "not
// mine", or no delimiter in the text) leaves the paste to the browser.
const onPaste = (e) => {
if (!freeTextOn()) return
const text = (e && e.clipboardData && e.clipboardData.getData('text')) || ''
// typeof checked inline (not via a local flag) so strict TS narrows the call.
const split = typeof $props.splitPaste === 'function' ? $props.splitPaste(text) : splitOnDelimiters(text)
if (!Array.isArray(split)) return
if (e) e.preventDefault()
const accepted = []
const rejected = []
for (let i = 0; i < split.length; i++) {
const part = String(split[i] == null ? '' : split[i]).trim()
if (!part) continue
const stored = storedText(part)
if (stored === null) rejected.push(part)
else accepted.push(stored)
}
const seps = splitDelimiters()
const rest = rejected.join(seps.length > 0 ? seps[0] + ' ' : ' ')
if (rest) insertAtCaret(e ? e.target : null, rest)
commitTexts(accepted)
}
// ---- input + keyboard handlers -----------------------------------------
const onInput = (e) => {
const q = e && e.target ? e.target.value : ''
setTypedText(q, null)
}
const onFocus = (e) => {
// Phase 86 R2 (plan 86-03), Solid-only reentrancy guard: the input now
// renders inside the composed popover's SCOPED `#anchor` slot
// (`:open="$props.open"` among its params — see the <Popover> template
// comment for why the input moved there). On Solid, a named slot invocation
// with reactive scope params is a plain closure CALL re-run whenever any
// param changes (@rozie/core's documented, intentional Solid
// slot-reactivity design — not a bug to route around at the emitter level):
// the `isOpen` write below changes the `open` param this exact handler is
// responding to, which on Solid SYNCHRONOUSLY recreates the anchor's DOM
// subtree (Solid's JSX has no virtual-DOM diffing to preserve node identity
// across a closure re-invocation) — removing the just-focused `<input>`
// fires a NATIVE blur on it, mid-call-stack, before this function even
// returns. Without the guard below, that blur's own onBlur() would
// immediately set isOpen back to false, and the deferred re-focus further
// down would restart the SAME cycle on the fresh node — an infinite
// recreate/blur/close/refocus loop. `openingInProgress` (below) tells
// onBlur "this blur is a side effect of OUR OWN isOpen write, not the user
// moving focus away" so it can skip closing. The other 5 targets diff their
// scoped-slot re-render and keep the existing, already-focused node — no
// blur ever fires there, so the guard is a no-op for them.
// disableOpenOnFocus (item 3): focus alone never opens the list — typing
// (onInput) and ArrowDown/ArrowUp (onKeydown) still do.
if ($props.disableOpenOnFocus) {
if (e && e.target && e.target.select) e.target.select()
return
}
openingInProgress = true
$data.isOpen = true
// Cleared SYNCHRONOUSLY, immediately after the write — Solid's reactive
// cascade (if any) runs SYNCHRONOUSLY as part of that write, before this
// line executes, so the guard window covers exactly the recreate/blur
// cascade and nothing past it. A deferred (microtask) clear would leave a
// stale `true` window spanning an `await` boundary whenever the re-focus
// below re-enters onFocus, incorrectly suppressing a LATER, genuine blur.
openingInProgress = false
if (e && e.target && e.target.select) e.target.select()
queueMicrotask(() => {
// Re-assert focus onto whatever node is CURRENT — after Solid's
// synchronous signal-write reactivity (if any) has already run and
// `$refs.inputEl` reflects the latest node — recovering focus if it was
// stranded on a since-removed one.
if ($refs.inputEl && document.activeElement !== $refs.inputEl) $refs.inputEl.focus()
})
}
// @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.
// While `pinned` (pinOpen(true)), early-return BEFORE the isOpen write — a host
// sub-surface (e.g. command-palette's action flyout) is holding focus and the
// popup must stay open until the host calls pinOpen(false) itself. While
// `openingInProgress` (Solid-only, see onFocus above), early-return too — this
// blur is a side effect of our OWN open-transition recreating the anchor's DOM,
// not the user moving focus elsewhere.
// commitOnBlur: leaving the field commits the typed text through validate (a blur
// into a pinned host sub-surface, or the Solid recreate blur, returned above).
const onBlur = (e) => {
if ($data.pinned) return
if (openingInProgress) return
$data.isOpen = false
if ($props.commitOnBlur && freeTextOn()) {
const el = e ? e.target : null
commitFreeText(el ? el.value : $data.inputText, el)
}
}
const onKeydown = (e) => {
// B10: ignore every key while an IME composition is active — the Enter that
// confirms a composition must never pick, commit or navigate. Read through
// `nativeEvent` when present: React's synthetic keyboard event does not carry
// `isComposing` (every other target hands the native event straight through).
const ne = e && e.nativeEvent ? e.nativeEvent : e
if (ne && (ne.isComposing || ne.keyCode === 229)) return
const key = e ? e.key : ''
const list = navRows()
// 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
const visible = popupVisible()
const liveText = e && e.target ? e.target.value : ''
const highlighted = wasOpen && ai >= 0 && list[ai] ? list[ai] : null
// Character delimiters (item 5): commit the TYPED text — never the highlighted
// option. 'Enter' / 'Tab' entries are handled in their own branches below.
if (freeTextOn() && key !== 'Enter' && key !== 'Tab' && delimiterList().indexOf(key) !== -1) {
if (e) e.preventDefault()
commitFreeText(liveText, e ? e.target : null)
return
}
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') {
// B9: Enter with Ctrl / Meta / Alt is left to the host (e.g. a send shortcut).
const modified = !!(e && (e.ctrlKey || e.metaKey || e.altKey))
if (!modified) {
if (highlighted) {
if (e) e.preventDefault()
selectOption(highlighted)
} else if (freeTextOn() && String(liveText).trim()) {
// Free-text mode (item 7): Enter with no highlighted option commits the
// typed text (rejected text stays in the input).
if (e) e.preventDefault()
commitFreeText(liveText, e ? e.target : null)
}
}
} else if (key === 'Tab') {
// selectOnTab (item 8): pick the highlighted option while the popup is
// visible; preventDefault ONLY when it picked. A 'Tab' delimiter commits the
// typed text when nothing was picked. Otherwise Tab moves focus normally.
if ($props.selectOnTab && visible && highlighted && !highlighted.disabled) {
if (e) e.preventDefault()
selectOption(highlighted)
} else if (freeTextOn() && delimiterList().indexOf('Tab') !== -1 && String(liveText).trim()) {
if (commitFreeText(liveText, e ? e.target : null) && e) e.preventDefault()
}
} else if (key === 'Escape') {
// B4: only consume Escape when the popup is actually VISIBLE.
if (visible) {
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)
}
} else if (key === 'Backspace') {
// Backspace-removes-last-chip (Tags.rozie precedent, Phase 86 R1 plan
// 86-05): guarded on `multiple` AND the LIVE input value being empty —
// read `e.target.value` directly (Tags' proven idiom), never the mirrored
// `$data.inputText`. A non-empty query falls through to normal text editing —
// nothing here removes a chip while there is text to delete.
if ($props.multiple) {
const liveValue = e && e.target ? e.target.value : ''
if (liveValue === '') {
const cur = selectedValues()
if (cur.length > 0) {
if (e) e.preventDefault()
removeChipValue(cur[cur.length - 1])
}
}
}
}
// Keep the (new) active option in view — routes through the virtualizer when
// windowing, direct scrollIntoView otherwise.
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)
}
}
// buildVirtualizer() (combobox-virtual-reactivity, VIRT-BUILD): the SINGLE virtualizer
// construction site — called from $onMount below (mount-time virtual:true) AND from the
// virtual $watch further down (a runtime false→true flip), so the mount path can never
// drift from the flip path. Guarded so a build queued (rAF-deferred by the $watch) that
// fires AFTER a flip-back is a no-op (rapid-flip idempotence), and so calling it twice
// never double-constructs.
const buildVirtualizer = () => {
if (!$props.virtual || virtualizer) return
// 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. 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.
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)
}
// teardownVirtualizer() (VIRT-TEARDOWN): runs the SAME per-instance cleanup fn
// $onUnmount invokes below, then nulls the instance state + bumps windowVer so the
// windowed template branch (still mounted while $props.virtual — CR-01) re-derives to
// the pre-construction fallback state instead of holding a stale virtualizer. This is
// the true→false ResizeObserver-leak fix: previously ONLY $onUnmount ever called
// virtualizerCleanup, so a runtime flip to non-virtual left the observer live.
const teardownVirtualizer = () => {
if (virtualizerCleanup) virtualizerCleanup()
virtualizer = null
virtualizerCleanup = null
gridScrollEl = null
$data.windowVer = $data.windowVer + 1
}
// nextAutoId(): a page-wide counter shared by every Rozie component instance. It
// lives on globalThis (read through Reflect, which type-checks in the plain-JS and
// the TS script alike) so separately bundled copies of a leaf never hand out the
// same id. The same four lines live in Combobox, Listbox and Popover.
const nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1
Reflect.set(globalThis, '__rozieAutoId', n)
return n
}
$onMount(() => {
if (!$props.idBase) $data.autoId = 'rozie-combobox-' + nextAutoId()
syncQueryToValue()
syncRows()
didMount = true
// Routes through the SAME buildVirtualizer() the virtual $watch calls below
// (VIRT-BUILD) — one construction site, so the mount path cannot drift from the flip
// path.
if ($props.virtual) buildVirtualizer()
})
// 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.inputText), () => {
if ($data.expandedGroups && Object.keys($data.expandedGroups).length) $data.expandedGroups = {}
syncRows()
if ($props.virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions())
virtualizer._willUpdate()
$data.windowVer = $data.windowVer + 1
scheduleRemeasure()
}
})
// Lazy watch (VIRT-WATCH): reflect a runtime `virtual` flip into the windowing engine.
// On ANY flip, reset grouped-expand state (mirroring the options/query watch above) —
// isGrouped()/isCapped() are pure-derived from props, so a flip BACK to non-virtual
// restores grouping automatically with no further action needed here. Build is
// rAF-deferred (the $onMount kick's own requestAnimationFrame/setTimeout fallback) so
// the `r-if="$props.virtual"` windowed branch has mounted its `.rozie-combobox-list`
// scroll container before buildVirtualizer()'s querySelector runs; teardown runs
// IMMEDIATELY (no frame with a live-but-orphaned virtualizer). Watches are lazy ×6
// (project_watch_onmount_ordering) — $onMount keeps its own mount-time branch untouched.
$watch(() => $props.virtual, () => {
if ($data.expandedGroups && Object.keys($data.expandedGroups).length) $data.expandedGroups = {}
if ($props.virtual) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => buildVirtualizer())
else setTimeout(() => buildVirtualizer(), 0)
} else {
teardownVirtualizer()
}
})
// 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. seedQuery(text) — imperative-only: write the input text
// (and therefore filteredOptions()'s filter) without touching the `value`
// model or selection state (a command-palette #2 levels/restore-on-pop
// prerequisite — repopulating the input on back-navigation is NOT a
// selection). pinOpen(v) — imperative-only: pin (or unpin) the popup open so
// onBlur() does not collapse it while a host sub-surface holds focus, AND
// (Phase 86-07 regression fix) so the composed Popover's OWN independent
// Escape/click-outside dismissal is vetoed too via `:disable-dismiss`
// (command-palette-sub-actions prerequisite). pinOpen(false) ONLY unpins — it
// does NOT itself close the popup or move focus; that is the host's job.
// Render-neutral when never called. All four are post-mount → $refs safe.
const focus = () => $refs.inputEl?.focus()
const clear = () => {
// Fresh empty array under `multiple` (never in-place mutation), null in
// single mode — mirrors selectOption()'s `{ value, option, selected }`
// shape; nothing is selected after a clear, so `selected` is `false`.
const empty = $props.multiple ? [] : null
$model.value = empty
clearQuery(null)
$data.activeIndex = -1
$emit('change', { value: empty, option: null, selected: false })
}
const seedQuery = (text) => {
$data.inputText = String(text == null ? '' : text)
}
const pinOpen = (v) => {
$data.pinned = !!v
}
// query() — the current input text (what the last `search` reported).
const query = () => $data.inputText
$expose({ focus, clear, seedQuery, pinOpen, activeOption, query }, {
focus: '() => void',
clear: '() => void',
seedQuery: '(text: string) => void',
pinOpen: '(v: boolean) => void',
activeOption: '() => any',
query: '() => string',
})
</script>
<template>
<div
class="rozie-combobox"
:class="{ 'rozie-combobox--open': $data.isOpen, 'rozie-combobox--disabled': $props.disabled, 'rozie-combobox--inline': $props.inline, 'rozie-combobox--multiple': $props.multiple, 'rozie-combobox--block': $props.block, 'rozie-combobox--chips-inline': chipsInline() }"
>
<!-- Composed popover positioning (Phase 86 R2/R4, plan 86-03) — ONE Popover wrapper
element now wraps the INPUT (as its `#anchor` slot fill) AND all FOUR
mutually-exclusive render branches (plain / grouped / grouped+capped /
windowed, as its default-slot fill). Plan 86-01 wrapped only the plain
branch's popup, with the input left as a sibling outside `<Popover>`; this
plan (a) brings the remaining three branches onto the same path — D-09
explicitly rejects a per-branch popover (or an `r-if="!inline"`
popover-branch + duplicated inline copy), which would multiply four
near-identical branches into eight — and (b) discovered, while proving R2's
"popup flips above the input" criterion with a REAL browser (not
happy-dom), that Popover's OWN internal anchor div — `.rozie-popover-anchor`
— is EMPTY when nothing fills its `#anchor` slot: Floating UI's
`computePosition` then measures a zero-size point instead of the control,
so the plain branch's popup rendered `width: 0` and mispositioned to the
right of the input rather than below it (a Floating UI ONLY manifests-under-
real-layout class of bug — no vitest/happy-dom test could have caught it),
AND the input, being OUTSIDE both `anchorEl`/`floatingEl`, was never
recognized as "inside" by popover's own document click-outside dismissal
(D-07) — a real click on the input to OPEN it immediately self-dismissed via
that SAME click bubbling to `document`. Filling `#anchor` with the input
fixes BOTH: `anchorEl` now measures the input's real box (correct position +
correct `matchWidth` reference), and `anchorEl.contains(target)` is true for
clicks on the input (no more self-dismiss-on-open-click). No Popover.rozie
change was needed — see the `.rozie-combobox-input` width rule below for the
one-line CSS fix this required on the combobox side (avoiding a percentage-
width circular reference through the anchor's shrink-to-fit sizing). Popover
itself is ALWAYS mounted (no r-if here) so the input stays permanently
visible; Popover's OWN internal r-if on its floating panel — gated on
`open || keepMounted` — is what continues to unmount the LIST on close for
the three non-virtual branches (byte-identical mount/unmount semantics to
before this plan) while `virtual`'s `keepMounted` forward (below) keeps it
hide-not-unmounted (CR-01 — the TanStack virtual-core scroll element +
measurements survive a close). `trigger="manual"` + `r-model:open` mean
popover's Escape/click-outside dismissal writes the SAME `$data.isOpen`
combobox would have written itself (D-07) — real click-outside dismissal,
which combobox did not have before (it only closed on blur).
`:disable-dismiss="$data.pinned"` (Phase 86-07 regression fix, D-24) vetoes
THAT SAME independent dismissal while a host sub-surface pins the popup
open via `pinOpen(true)` — Popover's document-level Escape/click-outside
listeners bypass `onBlur()` entirely (they write `isOpen` straight from a
`document`-level listener), so a click landing on a host-owned flyout
anchored to but not nested inside Popover's own anchorEl/floatingEl (e.g.
command-palette's action menu) was judged "outside" and silently closed
the list even while pinned. `bare`
suppresses popover's own chrome so `.rozie-combobox-list`'s own
background/border/radius/shadow keep supplying it (see the style block
below). `:match-width="true"` (D-04/D-12, always on) makes the popup span
the whole control box, replacing the `left: 0; right: 0` the list CSS used
to carry. The keepMounted forward below (D-10, bound to `$props.virtual`) is
never an author-facing combobox prop — always-mounting every branch would
ship a hidden listbox on the most-used (non-virtual) path.
`:disable-positioning="$props.inline"` (D-09) makes `inline` a static
pass-through: the popup renders in normal flow and popover never calls
computePosition/autoUpdate, preserving the overflow-safe embedded behavior
with ONE copy of every branch.
`:disable-dismiss="$props.inline || $data.pinned"` (86-REVIEW WR-01):
`:disable-positioning` alone does NOT imply no-dismissal — Popover's
document-level Escape/click-outside listeners are gated only on
`open && !disableDismiss`, independently of positioning. Before this
fix, an `inline` consumer (e.g. command-palette's un-pinned flow) still
got a real document-level Escape/click-outside listener it never had
pre-Phase-86 (it only closed on blur) — contradicting the SPEC's own
"the `inline` path involves no popover" acceptance criterion. Forwarding
`$props.inline` here restores that: `inline` gets neither positioning
nor dismissal from Popover, matching its pre-composition behavior. -->
<Popover
trigger="manual"
r-model:open="$data.isOpen"
bare
:match-width="true"
:keep-mounted="$props.virtual"
:disable-positioning="$props.inline"
:disable-dismiss="$props.inline || $data.pinned"
:placement="$props.placement"
:offset="$props.offset"
:disable-flip="$props.disableFlip"
:disable-shift="$props.disableShift"
:id-base="idRoot()"
>
<template #anchor>
<!-- Chip rail (Phase 86 R1, plan 86-05, D-13 locked one-way): renders
BEFORE the input, inside the SAME `#anchor` slot fill — so it is
measured as part of popover's `anchorEl` (see the comment above),
making the width-matched popup span chips + input, not the input
alone. Guarded solely on `multiple`; the whole rail is behind that
one guard so the control's rendered shape is byte-identical to
today whenever `multiple` is unset. Mirrors Tags.rozie's chip
markup exactly (the list-item wrapper, a scoped slot around the
default content, a label span, a focusable aria-labelled remove
button) — the one signature difference is the `#chip` slot scope,
`{ option, remove, index }` (D-18): the raw source option object,
mirroring how filteredOptions() already attaches the source option
to every wrapper row, not Tags' bare string token. -->
<!-- `.rozie-combobox-control` (release-0.8.0 token-input): a render-neutral
wrapper around chips + input — `display: contents` by default, so the
default render is unchanged. Under `block` it takes the root's width
(`100cqw` against the root query container — the popover-owned anchor
around it is shrink-to-fit, so a percentage would be circular); under
`chipLayout="inline"` it becomes the single wrapping flex row holding
the chips (the rail `<ul>` flattens to `display: contents`) and the input. -->
<div class="rozie-combobox-control">
<ul r-if="$props.multiple" class="rozie-combobox-chips">
<li
r-for="(row, idx) in chipRows()"
:key="'chip-' + row.value"
class="rozie-combobox-chip"
>
<slot name="chip" :option="row.option" :remove="() => onChipRemoveActivate(row.value)" :index="idx" :param-types="{ option: 'any', remove: '() => void', index: 'number' }">
<span class="rozie-combobox-chip__label">{{ row.label }}</span>
<button
type="button"
class="rozie-combobox-chip__remove"
:disabled="!!$props.disabled"
:aria-label="chipRemoveLabel(row)"
@mousedown.prevent="onChipRemovePointerDown()"
@click.stop="onChipRemoveActivate(row.value)"
>×</button>
</slot>
</li>
</ul>
<input
ref="inputEl"
class="rozie-combobox-input"
type="text"
role="combobox"
aria-autocomplete="list"
:aria-expanded="!!popupVisible()"
:aria-controls="listId()"
:aria-activedescendant="activeId()"
:aria-label="$props.ariaLabel"
:value="$data.inputText"
:placeholder="$props.placeholder"
:disabled="!!$props.disabled"
autocomplete="off"
@input="onInput($event)"
@focus="onFocus($event)"
@blur="onBlur($event)"
@keydown="onKeydown($event)"
@paste="onPaste($event)"
@change.stop="onNativeInputChange()"
/>
</div>
</template>
<!-- `<template #default>` wraps the four mutually-exclusive r-if branches
deliberately: several bare (unwrapped) r-if siblings passed straight as a
component's loose default-slot children crash @rozie/core's
extractSlotFillers (an astChild/loweredChildren index-alignment bug on
multiple loose r-if elements) — wrapping them in one explicit
`<template #default>` fill sidesteps the loose-children code path
entirely. -->
<template #default>
<!-- NON-VIRTUAL, UNGROUPED popup — the plain flat branch. `&& !isGrouped()` routes
grouped usage to the grouped/capped branches below (isGrouped() is false
whenever `groups` is empty and no option carries a `group`, so this branch is
the untouched flat path); `&& !$props.virtual` routes virtual usage to the
windowed branch further below. -->
<ul
r-if="popupVisible() && !$props.virtual && !isGrouped()"
class="rozie-combobox-list"
:id="listId()"
role="listbox"
:aria-multiselectable="$props.multiple ? 'true' : null"
>
<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': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }"
:id="optId(opt._i)"
role="option"
:aria-selected="!!isRowSelected(opt)"
: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="isRowSelected(opt)" :disabled="opt.disabled" :param-types="{ option: 'any', index: 'number', active: 'boolean', selected: 'boolean', disabled: 'boolean' }">{{ opt.label }}</slot>
</li>
<li r-if="filteredOptions().length === 0 && !isCreatableQuery()" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="$data.inputText" :param-types="{ query: 'string' }">No results</slot>
</li>
<!-- Creatable mode (Phase 86 R3, D-17/D-18/D-19): mirrors the +N more
row's shape exactly — a real option id, arrow-reachable, commits
through the SAME selectOption() dispatch (its isCreate branch
writes nothing to the model). Renders LAST, after every option —
R3's locked "renders after all options and group sections" is a
positional fact (it is the final sibling here), not a special
case. REPLACES the empty-state row above when the query is
creatable (D-19); coexists with real options whenever the query
substring-matches something but exact-matches nothing. -->
<li
r-if="isCreatableQuery()"
class="rozie-combobox-option rozie-combobox-create"
:id="optId(filteredOptions().length)"
role="option"
:class="{ 'rozie-combobox-option--active': filteredOptions().length === $data.activeIndex }"
@mousedown.prevent="selectOption(createRowAt(filteredOptions().length))"
@mouseenter="$data.activeIndex = filteredOptions().length"
>
<slot name="create" :query="$data.inputText" :param-types="{ query: 'string' }">Create "{{ $data.inputText }}"</slot>
</li>
</ul>
<!-- ══ NON-VIRTUAL, GROUPED popup (combobox-native-groups) — active ONLY when isGrouped()
(non-virtual + `groups` non-empty or an option carries `group`). Options render
partitioned into `role="group"` blocks (groupBlocks(), already visual-order-aligned
with filteredOptions()'s `_i`), each with an `aria-label` + `#groupHeading` slot
heading. Options inside a group are `<div role="option">` (NOT `<li>` — a nested
`<li>` inside the `<li role="group">` wrapper is invalid HTML); everything else
(classes/bindings/#option slot scope) is verbatim from the flat branch above. Headings
are presentation-only — never keyboard stops; the flat activeIndex/aria-activedescendant
keyboard model is untouched and walks the (already group-ordered) filteredOptions(). -->
<ul
r-if="popupVisible() && !$props.virtual && isGrouped() && !isCapped()"
class="rozie-combobox-list"
:id="listId()"
role="listbox"
:aria-multiselectable="$props.multiple ? 'true' : null"
>
<li
r-for="blk in groupBlocks()"
:key="'grp-' + (blk.group ? blk.group.id : '_ungrouped')"
class="rozie-combobox-group"
role="group"
:aria-label="blk.group ? blk.group.label : null"
>
<div r-if="blk.group" class="rozie-combobox-group-heading" role="presentation">
<slot name="groupHeading" :group="blk.group" :param-types="{ group: 'ComboboxGroup' }">{{ blk.group.label }}</slot>
</div>
<div
r-for="opt in blk.items"
:key="opt.value"
class="rozie-combobox-option"
:class="{ 'rozie-combobox-option--active': opt._i === $data.activeIndex, 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }"
:id="optId(opt._i)"
role="option"
:aria-selected="!!isRowSelected(opt)"
: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="isRowSelected(opt)" :disabled="opt.disabled" :param-types="{ option: 'any', index: 'number', active: 'boolean', selected: 'boolean', disabled: 'boolean' }">{{ opt.label }}</slot>
</div>
</li>
<li r-if="groupBlocks().length === 0 && !isCreatableQuery()" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="$data.inputText" :param-types="{ query: 'string' }">No results</slot>
</li>
<!-- Creatable mode (Phase 86 R3): renders LAST, after every group
section — mirrors the plain branch's create row above verbatim,
`_i` continues from filteredOptions().length (the same flattened
count groupBlocks() re-partitions without changing). -->
<li
r-if="isCreatableQuery()"
class="rozie-combobox-option rozie-combobox-create"
:id="optId(filteredOptions().length)"
role="option"
:class="{ 'rozie-combobox-option--active': filteredOptions().length === $data.activeIndex }"
@mousedown.prevent="selectOption(createRowAt(filteredOptions().length))"
@mouseenter="$data.activeIndex = filteredOptions().length"
>
<slot name="create" :query="$data.inputText" :param-types="{ query: 'string' }">Create "{{ $data.inputText }}"</slot>
</li>
</ul>
<!-- ══ NON-VIRTUAL, GROUPED + CAPPED popup (combobox-group-cap) — active ONLY when
isCapped() (isGrouped() + a positive groupCap). Each group renders its first
`cap` options (unless expanded or non-overflowing) followed by a keyboard-
reachable "+N more" row (cappedBlocks() re-indexes `_i` as a running counter
over the WHOLE visible+more sequence so ids/aria-activedescendant/navRows()
never disagree). Activating the more-row (mousedown or Enter via
selectOption()) expands ONLY that group in place — never writes the model or
fires change. Everything else (classes/bindings/#option/#groupHeading slot
scope) mirrors the grouped branch above verbatim. -->
<ul
r-if="popupVisible() && !$props.virtual && isCapped()"
class="rozie-combobox-list"
:id="listId()"
role="listbox"
:aria-multiselectable="$props.multiple ? 'true' : null"
>
<li
r-for="blk in cappedBlocks()"
:key="'grp-' + (blk.group ? blk.group.id : '_ungrouped')"
class="rozie-combobox-group"
role="group"
:aria-label="blk.group ? blk.group.label : null"
>
<div r-if="blk.group" class="rozie-combobox-group-heading" role="presentation">
<slot name="groupHeading" :group="blk.group" :param-types="{ group: 'ComboboxGroup' }">{{ blk.group.label }}</slot>
</div>
<div
r-for="opt in blk.items"
:key="opt.value"
class="rozie-combobox-option"
:class="{ 'rozie-combobox-option--active': opt._i === $data.activeIndex, 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }"
:id="optId(opt._i)"
role="option"
:aria-selected="!!isRowSelected(opt)"
: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="isRowSelected(opt)" :disabled="opt.disabled" :param-types="{ option: 'any', index: 'number', active: 'boolean', selected: 'boolean', disabled: 'boolean' }">{{ opt.label }}</slot>
</div>
<div
r-if="blk.more"
class="rozie-combobox-option rozie-combobox-more"
:id="optId(blk.more._i)"
role="option"
:class="{ 'rozie-combobox-option--active': blk.more._i === $data.activeIndex }"
@mousedown.prevent="selectOption(blk.more)"
@mouseenter="$data.activeIndex = blk.more._i"
>
<slot name="groupMore" :group="blk.group" :hidden="blk.more.hidden" :expand="blk.more.expand" :param-types="{ group: 'ComboboxGroup | null', hidden: 'number', expand: '() => void' }">+{{ blk.more.hidden }} more</slot>
</div>
</li>
<li r-if="cappedBlocks().length === 0 && !isCreatableQuery()" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="$data.inputText" :param-types="{ query: 'string' }">No results</slot>
</li>
<!-- Creatable mode (Phase 86 R3): renders LAST, after every group
section AND every "+N more" row — `_i` continues from
cappedRowCount() (the SAME running total cappedBlocks() itself
re-indexes across visible items + more-rows), so ids never
disagree with navRows(). -->
<li
r-if="isCreatableQuery()"
class="rozie-combobox-option rozie-combobox-create"
:id="optId(cappedRowCount())"
role="option"
:class="{ 'rozie-combobox-option--active': cappedRowCount() === $data.activeIndex }"
@mousedown.prevent="selectOption(createRowAt(cappedRowCount()))"
@mouseenter="$data.activeIndex = cappedRowCount()"
>
<slot name="create" :query="$data.inputText" :param-types="{ query: 'string' }">Create "{{ $data.inputText }}"</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 branches are gated on
$data.isOpen; this branch mirrors that via the :style display toggle). display:none
also drops the listbox role 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).
Popover's own keepMounted forward (above, D-10) keeps the PARENT
`.rozie-popover-floating` wrapper mounted-but-hidden across close/open too —
belt-and-suspenders with this <ul>'s own r-if="$props.virtual" (unconditional on
isOpen), which is what actually keeps the TanStack scroll element alive. -->
<ul
r-if="$props.virtual"
class="rozie-combobox-list rozie-combobox-list--virtual"
:id="listId()"
role="listbox"
:aria-multiselectable="$props.multiple ? 'true' : null"
:style="(popupVisible() ? '' : '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 windowedView()"
:key="wr.row.id"
class="rozie-combobox-option"
:class="{ 'rozie-combobox-option--active': wr.vi.index === $data.activeIndex, 'rozie-combobox-option--selected': isRowSelected(wr.row), 'rozie-combobox-option--disabled': wr.row.disabled }"
:id="optId(wr.vi.index)"
:data-index="wr.vi.index"
role="option"
:aria-selected="!!isRowSelected(wr.row)"
: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="isRowSelected(wr.row)" :disabled="wr.row.disabled" :param-types="{ option: 'any', index: 'number', active: 'boolean', selected: 'boolean', disabled: 'boolean' }">{{ wr.row.label }}</slot>
</li>
<li class="rozie-combobox-spacer" aria-hidden="true" :style="'height:' + padBottom() + 'px'"></li>
<li r-if="windowSource().length === 0 && !isCreatableQuery()" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="$data.inputText" :param-types="{ query: 'string' }">No results</slot>
</li>
<!-- Creatable mode (Phase 86 R3, D-17/A3): a SYNTHETIC non-option row
has no natural place inside virtual-core's measured window slice,
so it renders as a trailing sibling OUTSIDE the r-for loop —
mirroring how the empty-state row directly above already sits
outside it. windowSource() === filteredOptions() (same reference),
so `_i` continues from the same count the other three branches use. -->
<li
r-if="isCreatableQuery()"
class="rozie-combobox-option rozie-combobox-create"
:id="optId(windowSource().length)"
role="option"
:class="{ 'rozie-combobox-option--active': windowSource().length === $data.activeIndex }"
@mousedown.prevent="selectOption(createRowAt(windowSource().length))"
@mouseenter="$data.activeIndex = windowSource().length"
>
<slot name="create" :query="$data.inputText" :param-types="{ query: 'string' }">Create "{{ $data.inputText }}"</slot>
</li>
</ul>
</template>
</Popover>
</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, var(--rcb-width, 16rem));
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input {
box-sizing: border-box;
/* Phase 86 R2 (plan 86-03): EXPLICIT width, not `100%`. The input now renders
inside popover's `.rozie-popover-anchor` (`display: inline-block`,
shrink-to-fit) rather than as a direct 100%-width child of `.rozie-combobox`
(`width: var(--rozie-combobox-width, var(--rcb-width, 16rem))`) — a percentage width here would
be circular against that shrink-to-fit ancestor (CSS 2.1 §10.3.3: an
unresolvable percentage against an auto-width parent degrades to the
intrinsic/auto size, NOT the control's real width), which is exactly the
bug this fixes: `anchorEl`'s measured rect must equal the input's real box
for Floating UI's positioning AND `matchWidth`'s reference width to be
correct. Reads the SAME `--rozie-combobox-width` token `.rozie-combobox`
itself uses, so the rendered pixel width is IDENTICAL to before this change
in the default (non-inline) case. `.rozie-combobox--inline
.rozie-combobox-input` below restores `100%` for the inline pass-through
path, where `.rozie-combobox` itself stretches to its container (unaffected
by this fix — `disablePositioning` skips anchor measurement entirely there). */
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
padding: var(--rozie-combobox-input-padding, var(--rcb-input-padding, 0.5rem 0.75rem));
font: inherit;
color: var(--rozie-combobox-color, var(--rcb-color, inherit));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
/*
Render-neutral bottom-divider token (260715-50l finding 3). A longhand
AFTER the `border:` shorthand above so it wins on the bottom side; the
fallback REPLICATES the shorthand's own bottom (border-width solid
border-color) so default rendering is byte-for-render unchanged. Lets a
consumer (e.g. command-palette) render a borderless-with-underline input
without touching the other three sides.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25))));
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input:focus {
/* Decoupled from --rozie-combobox-accent (finding 3) so a consumer can */
/* neutralize the focus BORDER without touching the selected-option accent. */
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
/*
Same underline token, focus-colored fallback — the longhand keeps
WINNING on the bottom side over the :focus border-color override above,
so a consumer-set divider survives both blurred and focused states.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc))));
}
.rozie-combobox--disabled .rozie-combobox-input {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, var(--rcb-disabled-opacity, 0.55));
background: var(--rozie-combobox-disabled-bg, var(--rcb-disabled-bg, rgba(0, 0, 0, 0.04)));
}
.rozie-combobox-list {
margin: 0;
padding: var(--rozie-combobox-list-padding, var(--rcb-list-padding, 0.25rem));
list-style: none;
max-height: var(--rozie-combobox-list-max-height, var(--rcb-list-max-height, 16rem));
overflow-y: auto;
background: var(--rozie-combobox-list-bg, var(--rcb-list-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-list-border-color, var(--rcb-list-border-color, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
box-shadow: var(--rozie-combobox-list-shadow, var(--rcb-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16)));
}
/* Phase 86 R2 (plan 86-03): ALL FOUR popup <ul> branches now render NESTED
inside the composed popover's `.rozie-popover-floating` (itself positioned
by Floating UI + width-matched to the anchor via `matchWidth`), never as a
direct child of `.rozie-combobox` — so `.rozie-combobox-list` no longer
carries position/top/left/right/z-index anywhere; that geometry is
popover's exclusively now. (Plan 86-01 temporarily scoped this rule to a
`.rozie-combobox > .rozie-combobox-list` direct-child selector for the
three branches this plan finished composing; the selector is removed, not
merely emptied, since no branch is a direct child anymore.) */
.rozie-combobox-option {
padding: var(--rozie-combobox-option-padding, var(--rcb-option-padding, 0.4rem 0.6rem));
border-radius: var(--rozie-combobox-option-radius, var(--rcb-option-radius, 0.375rem));
cursor: pointer;
color: var(--rozie-combobox-option-color, inherit);
}
.rozie-combobox-option--active {
background: var(--rozie-combobox-option-active-bg, var(--rcb-option-active-bg, rgba(0, 102, 204, 0.12)));
}
.rozie-combobox-option--selected {
font-weight: var(--rozie-combobox-option-selected-weight, var(--rcb-option-selected-weight, 600));
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, var(--rcb-option-selected-color, var(--rcb-accent, #0066cc))));
}
.rozie-combobox-option--disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-empty {
padding: var(--rozie-combobox-empty-padding, var(--rcb-empty-padding, 0.5rem 0.6rem));
color: var(--rozie-combobox-empty-color, var(--rcb-empty-color, rgba(0, 0, 0, 0.5)));
list-style: none;
}
/* Native option grouping (combobox-native-groups): the `role="group"` <li> is a bare,
non-interactive wrapper (no padding of its own — the heading + option children carry
their own spacing); the heading is small/muted/non-interactive presentation text. */
.rozie-combobox-group {
list-style: none;
}
.rozie-combobox-group-heading {
/* Render-neutral section-separation token (260715-50l finding 4) — default */
/* 0 = unchanged; a consumer-set value separates the leading ungrouped */
/* block from the first group heading. */
margin-top: var(--rozie-combobox-group-heading-margin-top, var(--rcb-group-heading-margin-top, 0));
padding: var(--rozie-combobox-group-heading-padding, var(--rcb-group-heading-padding, 0.35rem 0.6rem 0.15rem));
font-size: var(--rozie-combobox-group-heading-size, var(--rcb-group-heading-size, 0.75rem));
font-weight: var(--rozie-combobox-group-heading-weight, var(--rcb-group-heading-weight, 600));
text-transform: var(--rozie-combobox-group-heading-transform, var(--rcb-group-heading-transform, uppercase));
letter-spacing: var(--rozie-combobox-group-heading-letter-spacing, var(--rcb-group-heading-letter-spacing, 0.03em));
color: var(--rozie-combobox-group-heading-color, var(--rcb-group-heading-color, rgba(0, 0, 0, 0.5)));
pointer-events: none;
user-select: none;
}
/* Per-group result cap "+N more" row (combobox-group-cap): a muted, pointer-
cursor affordance row — reads as interactive but keeps the option-row
sizing/active-state styling of a regular option (the shared
.rozie-combobox-option--active rule still applies). */
.rozie-combobox-more {
cursor: pointer;
color: var(--rozie-combobox-more-color, var(--rcb-more-color, rgba(0, 0, 0, 0.55)));
font-size: var(--rozie-combobox-more-size, var(--rcb-more-size, 0.875rem));
}
/* Creatable mode (Phase 86 R3): the trailing "Create …" row — reads as
interactive but keeps the shared .rozie-combobox-option sizing/active-state
styling of a regular option (the .rozie-combobox-option--active rule still
applies). Fully token-driven; the two --rozie-combobox-create* tokens are
declared with their documentation prose in themes/base.css (plan 86-06 task 3). */
.rozie-combobox-create {
cursor: pointer;
color: var(--rozie-combobox-create-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
background: var(--rozie-combobox-create-bg, var(--rcb-create-bg, transparent));
}
/* 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; }
/* The windowing engine compensates for options above the viewport changing height itself.
Browser scroll anchoring moved the view again when the leading spacer changed height, and
that move was indistinguishable from the user scrolling away from the end (measured on the
Listbox twin: the scroll-end pin was cleared at a 38px-short position). */
.rozie-combobox-list--virtual { overflow-anchor: none; }
/* Chip rail (Phase 86 R1, plan 86-05, D-13): renders inside popover's
`#anchor` slot fill, before the input — see the template comment. Chips
wrap; the control grows vertically as they do. Fully token-driven (mirrors
Tags.rozie's own chip theme) so it works with zero config yet is completely
re-skinnable; the ten `--rozie-combobox-chip*` tokens are declared with
their documentation prose in themes/base.css (plan 86-05 task 2). */
.rozie-combobox-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
padding: var(--rozie-combobox-chips-padding, var(--rcb-chips-padding, 0.35rem 0.45rem 0 0.45rem));
margin: 0;
list-style: none;
}
.rozie-combobox-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: var(--rozie-combobox-chip-padding, var(--rcb-chip-padding, 0.15rem 0.5rem));
font-size: var(--rozie-combobox-chip-size, var(--rcb-chip-size, 0.85rem));
color: var(--rozie-combobox-chip-color, inherit);
background: var(--rozie-combobox-chip-bg, var(--rcb-chip-bg, rgba(0, 102, 204, 0.12)));
border-radius: var(--rozie-combobox-chip-radius, var(--rcb-chip-radius, 0.375rem));
white-space: nowrap;
}
.rozie-combobox-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
height: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
padding: 0;
font: inherit;
line-height: 1;
color: var(--rozie-combobox-chip-remove-color, var(--rcb-chip-remove-color, currentColor));
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
transition: color 0.15s;
}
.rozie-combobox-chip__remove:hover:not(:disabled) {
color: var(--rozie-combobox-chip-remove-hover-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
}
.rozie-combobox-chip__remove:disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
/* Token-input layout (release-0.8.0). `.rozie-combobox-control` wraps chips +
input inside popover's (shrink-to-fit) anchor; `display: contents` keeps the
default render unchanged. `block` / `chipLayout="inline"` make the root a
query container so the control can take its width as `100cqw` (a percentage
would be circular against the shrink-to-fit anchor — see the input rule). */
.rozie-combobox-control {
display: contents;
}
.rozie-combobox--block {
display: block;
width: 100%;
container-type: inline-size;
}
.rozie-combobox--block .rozie-combobox-control {
display: block;
width: 100cqw;
}
.rozie-combobox--block .rozie-combobox-input {
width: 100%;
}
.rozie-combobox--chips-inline {
container-type: inline-size;
}
.rozie-combobox--chips-inline .rozie-combobox-control {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
width: 100cqw;
padding: var(--rozie-combobox-inline-padding, var(--rcb-inline-padding, 0.3rem 0.45rem));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox--chips-inline .rozie-combobox-control:focus-within {
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
}
.rozie-combobox--chips-inline .rozie-combobox-chips {
display: contents;
}
.rozie-combobox--chips-inline .rozie-combobox-input,
.rozie-combobox--chips-inline .rozie-combobox-input:focus {
flex: 1 1 var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
width: auto;
min-width: var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
padding: var(--rozie-combobox-inline-input-padding, var(--rcb-inline-input-padding, 0.2rem 0.25rem));
background: transparent;
border: none;
box-shadow: 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` dropped (plan 86-03): `.rozie-combobox-list` carries no
absolute positioning to undo anymore — that geometry lives on popover's
`.rozie-popover-floating`, and `:disable-positioning="$props.inline"`
(D-09) already renders it as a static pass-through via popover's own
`.rozie-popover-floating--static` rule. */
margin-top: var(--rozie-combobox-list-gap, var(--rcb-list-gap, 0.25rem));
border: none;
border-radius: 0;
box-shadow: none;
}
/* Phase 86 R2 (plan 86-03): restore the input's ORIGINAL `100%`-of-container
sizing under `inline` — `.rozie-combobox--inline` stretches the whole control
to its embedding container's width (e.g. command-palette's panel), and the
input should fill that, not fall back to the fixed `--rozie-combobox-width`
token the non-inline default above now uses. `disablePositioning` (forwarded
whenever `inline`) means popover never measures `anchorEl`'s rect at all, so
there is no circular-width hazard to avoid here — this override is purely a
visual byte-identity preservation for the embedded case. */
.rozie-combobox--inline .rozie-combobox-input {
width: 100%;
}
</style>
</rozie>…and Rozie compiles it to six 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';
import Popover from '@rozie-ui/popover-react';
// 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';
// ---- native option grouping (combobox-native-groups: src/internal/groupOptions.ts) ----
// The PURE stable-partition helper is a RUNTIME import (unlike listCore/windowing
// above, it is NOT a compile-time `.rzts` partial that dissolves at compile) —
// codegen's `copyInternal` vendors it verbatim into each leaf at
// `./internal/groupOptions`, mirroring command-palette's `scoreCommands.ts`.
import { groupOptions } from './internal/groupOptions';
// 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.
// The typed public surface (typed-surface P1; always TypeScript). `value` /
// `option` stay `any`: options are consumer-shaped objects (or primitives) the
// component never inspects beyond the label/value/disabled resolvers.
/** `search` payload — the current input text. */
export interface ComboboxSearchPayload {
query: string;
}
/** `change` payload — `option` is the raw source option (`null` for a clear or a free-text commit); `text` is set ONLY on free-text commits. */
export interface ComboboxChangePayload {
value: any;
option: any;
selected: boolean;
text?: string;
}
/** `create` payload — the (untrimmed) query the user asked to create. */
export interface ComboboxCreatePayload {
query: string;
}
/** An entry of the `groups` prop. */
export interface ComboboxGroup {
id: string;
label: string;
}
/** `chip` slot params — `remove()` removes the chip and refocuses the input. */
export interface ComboboxChipSlotCtx {
option: any;
remove: () => void;
index: number;
}
/** `option` slot params. */
export interface ComboboxOptionSlotCtx {
option: any;
index: number;
active: boolean;
selected: boolean;
disabled: boolean;
}
/** `empty` / `create` slot params. */
export interface ComboboxQuerySlotCtx {
query: string;
}
/** `groupHeading` slot params. */
export interface ComboboxGroupHeadingSlotCtx {
group: ComboboxGroup;
}
/** `groupMore` slot params. */
export interface ComboboxGroupMoreSlotCtx {
group: ComboboxGroup | null;
hidden: number;
expand: () => void;
}
interface ChipCtx { option: any; remove: () => void; index: number; }
interface OptionCtx { option: any; index: number; active: boolean; selected: boolean; disabled: boolean; }
interface EmptyCtx { query: string; }
interface CreateCtx { query: string; }
interface GroupHeadingCtx { group: ComboboxGroup; }
interface GroupMoreCtx { group: ComboboxGroup | null; hidden: number; expand: () => void; }
interface ComboboxProps extends Omit<import('react').ComponentPropsWithoutRef<'div'>, 'value' | 'defaultValue' | 'onValueChange' | 'options' | 'placeholder' | 'disabled' | 'disableFilter' | 'ariaLabel' | 'idBase' | 'inline' | 'closeOnSelect' | 'multiple' | 'creatable' | 'optionLabel' | 'optionValue' | 'optionDisabled' | 'virtual' | 'estimateRowHeight' | 'maxHeight' | 'groups' | 'groupCap' | 'placement' | 'offset' | 'disableFlip' | 'disableShift' | 'block' | 'chipLayout' | 'disableOpenOnFocus' | 'hideEmpty' | 'delimiters' | 'validate' | 'splitPaste' | 'commitOnBlur' | 'selectOnTab' | 'onSearch' | 'onChange' | 'onCreate' | 'renderChip' | 'renderOption' | 'renderEmpty' | 'renderCreate' | 'renderGroupHeading' | 'renderGroupMore' | 'slots' | 'children' | 'dangerouslySetInnerHTML'> {
/**
* 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 value={country} onValueChange={setCountry} options={countries} />
*/
value?: (unknown) | null;
defaultValue?: (unknown) | null;
onValueChange?: (value: (unknown) | null) => void;
/**
* The option list — `[{ value, label, disabled?, group? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, an optional `disabled` flag makes an option non-selectable, and an optional `group` string buckets the option under a matching entry of the `groups` prop (or a first-appearance fallback section) when grouping is active.
*/
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, option and popup elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`, the listbox id is `idBase + "-list"`. Leave it empty (the default) and each instance generates a unique id base after mount (`rozie-combobox-<n>`); set it when you need stable, predictable ids. 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. Unset (default) resolves through `effectiveCloseOnSelect()`: `true` in single-select (today's default behavior) and `false` in `multiple` mode, where closing after every chip pick would make multi-select unusable. Pass an explicit `true` or `false` to override in either mode.
*/
closeOnSelect?: (boolean) | null;
/**
* `value` widens to hold an **array** of selected values and remains the sole `model: true` prop, so the Angular `ControlValueAccessor` is preserved (a second model would forfeit it — `ROZ125`). Re-selecting an already-selected option toggles it off. Default `false` is byte-identical to single-select.
*/
multiple?: boolean;
/**
* When the user commits text matching no option (case-insensitive, trimmed, exact label equality — no Unicode normalization applied), combobox emits `create` with the query and writes NOTHING to `value` — the consumer adds the option to `options` and updates the model itself. Composes with `multiple`. Turning this on replaces the `#empty` fill with the `#create` row whenever the query is creatable (non-empty, no exact match); `#empty` still renders for an empty or whitespace-only query. Default `false` is byte-identical to today.
*/
creatable?: 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;
/**
* Ordered section list `[{ id, label }]` setting group order + heading text. Options are partitioned by their optional `group?` string; groups present on options but absent here fall back to first-appearance order after the listed ones. Empty/absent ⇒ flat, ungrouped rendering (default).
*/
groups?: any[];
/**
* Cap each native section group to its first `groupCap` results, adding a keyboard-reachable '+N more' row that expands that group IN PLACE when activated. `0`/absent = uncapped (default). Only applies to the non-virtual grouped render (`groups` non-empty); ignored when `virtual` is on.
*/
groupCap?: number;
/**
* Floating UI placement of the popup relative to the control, forwarded to the composed `@rozie-ui/popover` leaf — one of `top`/`right`/`bottom`/`left`, each optionally suffixed `-start`/`-end`. Default `"bottom-start"` matches the pre-Phase-86 static popup alignment (flush with the control's left edge). Ignored when `inline` is set.
*/
placement?: string;
/**
* Gap in pixels between the control and the popup, forwarded to the composed `@rozie-ui/popover` leaf. Default `4` preserves the pre-Phase-86 resting gap (`--rozie-combobox-list-gap`). Ignored when `inline` is set.
*/
offset?: number;
/**
* Disable the popup's Floating UI `flip` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup flips above the control when it would overflow the viewport below; set this to keep it pinned to `placement` regardless. Ignored when `inline` is set.
*/
disableFlip?: boolean;
/**
* Disable the popup's Floating UI `shift` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup shifts to stay within the viewport; set this to keep it strictly aligned to the control. Ignored when `inline` is set.
*/
disableShift?: boolean;
/**
* Fill the container: the root becomes `display: block; width: 100%`, the control (chips + input) stretches to that width, and the width-matched popup follows. Adds the `rozie-combobox--block` modifier class on the root. Default `false` keeps the fixed `--rozie-combobox-width` sizing.
*/
block?: boolean;
/**
* Chip rail layout under `multiple`: `'stacked'` (default) renders the chips above the input; `'inline'` puts the chips and the input on ONE wrapping row (the Tags layout), with the input taking the remaining width (`flex: 1`, never narrower than `--rozie-combobox-inline-input-min-width`). Only meaningful with `multiple`.
*/
chipLayout?: string;
/**
* Do not open the list when the input gains focus. Typing and ArrowDown / ArrowUp still open it. Default `false` opens on focus.
*/
disableOpenOnFocus?: boolean;
/**
* Show nothing instead of the empty state: when there are no option rows and no create row, the popup is not shown, the input reports `aria-expanded="false"`, and Escape is left to the host (not `preventDefault`ed). This is the supported way to render no popup at all; filling the `empty` slot with nothing still renders the fallback on most targets.
*/
hideEmpty?: boolean;
/**
* Keys that commit the **typed text** as a value (matched against the key event's `key`), under `multiple` only — a delimiter never picks the highlighted option. Character entries (e.g. `[',', ';']`) also split pasted text: a paste containing a delimiter is split on them, every non-empty trimmed part that `validate` accepts is committed, and the rejected parts are inserted at the caret (replacing the selection) like an ordinary paste, so text typed before the paste is kept. Use `splitPaste` to replace this split. `'Enter'` and `'Tab'` are allowed; Enter then commits the typed text only when no option is highlighted. A non-empty list (or `validate`, `splitPaste` or `commitOnBlur`) turns on free-text commits, so Enter with no highlighted option commits the typed text too. Default `[]` (off).
* @example
* <Combobox multiple value={to} onValueChange={setTo} options={contacts} delimiters={delims} />
*/
delimiters?: any[];
/**
* Free-text gate and normaliser, `(text: string) => string | boolean | null | undefined`, under `multiple` only. Called with the trimmed typed (or pasted) text before every free-text commit. Return the **string to store** (e.g. the bare address out of `Sam Roe <sam@x.test>`), `true` to store the text as typed, or a falsy value (`false` / `null` / `''`) to reject it — rejected text stays in the input. The same shape as Tags' `validate`. Setting it also turns on free-text commits (Enter with no highlighted option commits the typed text). A free-text commit appends the stored string to `value` (skipped when already present), clears the input, and emits `change` with `option: null` and the stored string as `text`.
* @example
* <Combobox multiple value={to} onValueChange={setTo} options={contacts} validate={toAddress} />
*/
validate?: ((...args: any[]) => any) | null;
/**
* Replaces the built-in paste split, `(text: string) => string[] | null`, under `multiple` only. Called with the clipboard text on every paste. Return the parts to commit — each is trimmed and passed through `validate`; accepted parts are committed and the rejected ones are inserted at the caret — or `null` to leave the paste to the browser untouched. Use it for syntax the delimiter split cannot know about, e.g. a quoted display name containing a comma (`"Roe, Sam" <sam@x.test>`). Setting it also turns on free-text commits.
* @example
* <Combobox multiple value={to} onValueChange={setTo} options={contacts} validate={toAddress} splitPaste={splitAddresses} />
*/
splitPaste?: ((...args: any[]) => any) | null;
/**
* Commit the typed text when the input loses focus, under `multiple` only, through `validate` like every other free-text commit: accepted text is committed and the input cleared, rejected text stays. A blur into a pinned host sub-surface (`pinOpen(true)`) does not commit. Setting it also turns on free-text commits. Default `false`.
*/
commitOnBlur?: boolean;
/**
* Tab picks the highlighted option while the popup is visible and an option is highlighted, keeping focus in the input. When nothing is picked, Tab moves focus normally. Default `false` (Tab always moves focus).
*/
selectOnTab?: boolean;
onSearch?: (payload: ComboboxSearchPayload) => void;
onChange?: (payload: ComboboxChangePayload) => void;
onCreate?: (payload: ComboboxCreatePayload) => void;
renderChip?: (ctx: ChipCtx) => ReactNode;
renderOption?: (ctx: OptionCtx) => ReactNode;
renderEmpty?: (ctx: EmptyCtx) => ReactNode;
renderCreate?: (ctx: CreateCtx) => ReactNode;
renderGroupHeading?: (ctx: GroupHeadingCtx) => ReactNode;
renderGroupMore?: (ctx: GroupMoreCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface ComboboxHandle {
focus: () => void;
clear: () => void;
seedQuery: (text: string) => void;
pinOpen: (v: boolean) => void;
activeOption: () => any;
query: () => string;
}
const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox(_props: ComboboxProps, ref): JSX.Element {
const __defaultOptions = useState(() => (() => [])())[0];
const __defaultGroups = useState(() => (() => [])())[0];
const __defaultDelimiters = useState(() => (() => [])())[0];
const props: Omit<ComboboxProps, 'options' | 'placeholder' | 'disabled' | 'disableFilter' | 'ariaLabel' | 'idBase' | 'inline' | 'closeOnSelect' | 'multiple' | 'creatable' | 'optionLabel' | 'optionValue' | 'optionDisabled' | 'virtual' | 'estimateRowHeight' | 'maxHeight' | 'groups' | 'groupCap' | 'placement' | 'offset' | 'disableFlip' | 'disableShift' | 'block' | 'chipLayout' | 'disableOpenOnFocus' | 'hideEmpty' | 'delimiters' | 'validate' | 'splitPaste' | 'commitOnBlur' | 'selectOnTab'> & { options: any[]; placeholder: string; disabled: boolean; disableFilter: boolean; ariaLabel: (string) | null; idBase: string; inline: boolean; closeOnSelect: (boolean) | null; multiple: boolean; creatable: boolean; optionLabel: ((...args: any[]) => any) | null; optionValue: ((...args: any[]) => any) | null; optionDisabled: ((...args: any[]) => any) | null; virtual: boolean; estimateRowHeight: number; maxHeight: string; groups: any[]; groupCap: number; placement: string; offset: number; disableFlip: boolean; disableShift: boolean; block: boolean; chipLayout: string; disableOpenOnFocus: boolean; hideEmpty: boolean; delimiters: any[]; validate: ((...args: any[]) => any) | null; splitPaste: ((...args: any[]) => any) | null; commitOnBlur: boolean; selectOnTab: boolean } = {
..._props,
options: _props.options ?? __defaultOptions,
placeholder: _props.placeholder ?? '',
disabled: _props.disabled ?? false,
disableFilter: _props.disableFilter ?? false,
ariaLabel: _props.ariaLabel ?? null,
idBase: _props.idBase ?? '',
inline: _props.inline ?? false,
closeOnSelect: _props.closeOnSelect ?? null,
multiple: _props.multiple ?? false,
creatable: _props.creatable ?? false,
optionLabel: _props.optionLabel ?? null,
optionValue: _props.optionValue ?? null,
optionDisabled: _props.optionDisabled ?? null,
virtual: _props.virtual ?? false,
estimateRowHeight: _props.estimateRowHeight ?? 36,
maxHeight: _props.maxHeight ?? '',
groups: _props.groups ?? __defaultGroups,
groupCap: _props.groupCap ?? 0,
placement: _props.placement ?? 'bottom-start',
offset: _props.offset ?? 4,
disableFlip: _props.disableFlip ?? false,
disableShift: _props.disableShift ?? false,
block: _props.block ?? false,
chipLayout: _props.chipLayout ?? 'stacked',
disableOpenOnFocus: _props.disableOpenOnFocus ?? false,
hideEmpty: _props.hideEmpty ?? false,
delimiters: _props.delimiters ?? __defaultDelimiters,
validate: _props.validate ?? null,
splitPaste: _props.splitPaste ?? null,
commitOnBlur: _props.commitOnBlur ?? false,
selectOnTab: _props.selectOnTab ?? false,
};
const attrs: Record<string, unknown> = (() => {
const { value, options, placeholder, disabled, disableFilter, ariaLabel, idBase, inline, closeOnSelect, multiple, creatable, optionLabel, optionValue, optionDisabled, virtual, estimateRowHeight, maxHeight, groups, groupCap, placement, offset, disableFlip, disableShift, block, chipLayout, disableOpenOnFocus, hideEmpty, delimiters, validate, splitPaste, commitOnBlur, selectOnTab, defaultValue, onValueChange, onSearch, onChange, onCreate, ...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 multiple; void creatable; void optionLabel; void optionValue; void optionDisabled; void virtual; void estimateRowHeight; void maxHeight; void groups; void groupCap; void placement; void offset; void disableFlip; void disableShift; void block; void chipLayout; void disableOpenOnFocus; void hideEmpty; void delimiters; void validate; void splitPaste; void commitOnBlur; void selectOnTab; void defaultValue; void onValueChange; void onSearch; void onChange; void onCreate;
return rest;
})();
const didMount = useRef(false);
const virtualizer = useRef<any>(null);
const gridScrollEl = useRef<any>(null);
const virtualizerCleanup = useRef<any>(null);
const measuredRowCount = useRef(0);
const measuredRowTotal = useRef(0);
const windowVerBumpPending = useRef(false);
const remeasurePending = useRef(false);
const scrollEndPinnedTop = useRef<number>(-1);
const scrollEndPinned = useRef<boolean>(false);
const scrollEndPinnedCount = useRef<number>(-1);
const openingInProgress = useRef(false);
const [value, setValue] = useControllableState({
value: props.value,
defaultValue: props.defaultValue ?? null,
onValueChange: props.onValueChange,
});
const _idBaseRef = useRef(props.idBase);
_idBaseRef.current = props.idBase;
const _virtualRef = useRef(props.virtual);
_virtualRef.current = props.virtual;
const [inputText, setInputText] = 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 [expandedGroups, setExpandedGroups] = useState<Record<string, any>>({});
const [createdQuery, setCreatedQuery] = useState<any>(null);
const [pinned, setPinned] = useState(false);
const [autoId, setAutoId] = useState('');
const inputEl = useRef<HTMLInputElement | null>(null);
const __rozieRoot = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
// ══ 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`).
// - `idRoot()` — the host's id base (Listbox: the `id` prop, else the per-instance id it
// generates in $onMount); `optionId` below derives every option id from it.
// - `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.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 (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;
}
// `idRoot()` is a HOST function (the host's id base: its id prop, else a generated
// per-instance id) so the option ids follow the host's auto-id fallback.
// ══ 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.
// - rowsWindowed(): boolean — is the ROW axis windowed. REQUIRED, no default — replaces every bare
// truthiness read of the host's windowing prop (D-05); `windowedRows()` /
// `padTop()` / `padBottom()` / `rowIsOutsideWindow()` below call it by
// convention exactly as they already call `pinnedEditIndex()`.
// - colsWindowed(): boolean — is the COLUMN axis windowed. REQUIRED, no default. `false` for every
// host until it defines the real column-axis mechanism (87-04+).
// - columnCount(): number — the leaf-column count the column virtualizer windows over. REQUIRED,
// no default.
// - columnSize(i: number): number — the authoritative width of absolute leaf column `i`, sourced
// from table-core's `getSize()` under D-06. REQUIRED, no default.
// - forcedColumns(): number[] — the D-10 OPTIONAL column-axis mirror of `pinnedEditIndex()`: the
// DataTable host unions pinned + active-cell + editing column indices into
// the column-window slice; listbox/combobox pass an empty array (host-
// provided, defaulting to `[]`).
// - colVirtualizer — the host's SECOND virtual-core instance, windowing the COLUMN axis
// (see the AXIS MECHANISM note below). Host-provided, defaulting to `null`.
// - autoMeasureOn(): boolean — the D-18 REQUIRED content-driven-estimate gate (Phase 87 87-07):
// data-table's real body reads `$props.autoMeasure === true`; listbox/
// combobox/command-palette return `false` so the accumulator branch
// estimateRowSize() gates on is dead code for them (D-20).
// - afterRowRemeasure — OPTIONAL host-owned mutable `let` (defaults to a no-op / undefined),
// assigned to refineRowEstimate() (below) by the host. The DataTable
// host's remeasureWindow() (virtualization.rzts) calls it AFTER its
// measureElement sweep so the fold + hysteresis re-feed run on every
// window commit. Routed through a mutable `let` rather than a direct
// call FROM virtualization.rzts INTO this file: a relative-partial CONST
// calling a bare-specifier-partial CONST is the exact forward-reference
// TDZ class remeasureColumnWindow()'s own DataTable.rozie comment
// documents for columnVirtualizerOptions() (inlineScriptPartials()
// groups the relative partial BEFORE the bare-specifier partial in the
// merged per-target output, regardless of source import order). A
// mutable `let` hoists to `useRef` on React and is excluded from a
// useCallback's dependency array, sidestepping the hazard entirely — the
// SAME mechanism `refreshRowModel` already relies on.
//
// AXIS MECHANISM (OQ1 / Assumption A1 — resolved from the installed source in 87-02;
// LANDED in 87-04: `columnVirtualizerOptions()` below IS the second, horizontal instance this
// note originally only documented). `horizontal` is a PER-INSTANCE field of `VirtualizerOptions`
// (`node_modules/@tanstack/virtual-core/dist/esm/index.d.ts:67`, installed version 3.17.1 per
// `package.json`), and every axis-sensitive internal read consults `instance.options.horizontal` —
// `measureElement`'s inlineSize/blockSize + offsetWidth/offsetHeight branch
// (`dist/esm/index.js:137,150`), `observeElementOffset`'s scrollLeft/scrollTop branch
// (`dist/esm/index.js:118-121`), `getMaxScrollOffset`'s scrollWidth/scrollHeight branch
// (`dist/esm/index.js:907-915`), and `scrollWithAdjustments`'s left/top branch
// (`dist/esm/index.js:152-161`). So ONE `Virtualizer` instance windows exactly ONE axis: the column
// axis needs its own SECOND, independent `Virtualizer` instance constructed with `horizontal: true`,
// sharing the SAME `getScrollElement()` (the `rdt-scroll` wrapper) the row instance already uses.
// Two options the row axis does not set that the column instance will need: `isRtl?: boolean`
// (data-table ships an RTL grid path) and `overscan?: number` (D-07 gives the column axis its own
// hardcoded constant, separate from the row axis's `overscan: 8` below).
//
// isRtl WIRING (gap-closure 87-09, LANDED — see `ensureColRtlWatch()`/`isColRtl()` below,
// immediately ahead of `columnVirtualizerOptions()`): data-table has no construction-time RTL
// signal (no `dir`/`rtl` prop), and `dir` can be set on `gridScrollEl` at ANY point relative to
// mount. `isRtl` is therefore computed LIVE via `getComputedStyle`, not baked in once.
// 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;
}
// COL_OVERSCAN (D-07): the column axis's own hardcoded overscan constant, separate from the
// row axis's `overscan: 8` below. Columns are far wider than rows are tall, so one number
// cannot serve both axes; no prop is exposed because no consumer has asked to tune the row
// overscan across the four phases it has shipped. Unused until 87-04 constructs the second,
// horizontal Virtualizer instance (see the AXIS MECHANISM note above).
// ══ Phase 87 87-07 (D-15/D-18) — content-driven auto-measure: the shared engine's FIRST
// mutable top-level state. Hoisted to `useRef` PER-INSTANCE by the React emitter's
// hoistModuleLet — the SAME mechanism already load-bearing for `table`, `virtualizer`,
// `remeasurePending`, and `gridScrollEl` in the DataTable host (Task 1's confirmed
// precedent), so two DataTable instances on one page never share an accumulator
// (T-87-07-04). measuredRowTotal/measuredRowCount together give the running MEAN of every
// row folded in so far; lastFedRowEstimate is the estimate value most recently pushed into
// virtual-core (the hysteresis comparison baseline). ══
// ══ Gap-closure 87-10 — windowVerBumpPending / bumpWindowVer(): coalesce EVERY $data.windowVer
// write behind a SINGLE microtask-deferred increment, regardless of how many callers request
// one within the same synchronous JS task. ══
//
// ROOT CAUSE (framework-agnostic; the Solid-specific symptom this closes only EXPOSES it) —
// confirmed by instrumenting the installed @tanstack/virtual-core@3.17.1 source directly
// (dist/esm/index.js), not by reasoning abstractly: virtual-core's resizeItem() calls
// `this.notify(false)` — synchronously invoking `virtualizerOptions().onChange` below — EVERY
// TIME a measured row's real size differs from its cached one (`delta !== 0`), independent of
// framework (dist/esm/index.js:836-874). remeasureWindow()'s CR-01 sweep
// (packages/ui/data-table/src/virtualization.rzts) measures EVERY currently-rendered `<tr>` in
// ONE for-loop BEFORE calling afterRowRemeasure() (refineRowEstimate() below) — so a single
// synchronous JS task (e.g. the very first measurement pass, which transitions N never-before-
// measured rows from the flat seed to their real heights) can fire onChange, and therefore an
// UNCOALESCED `$data.windowVer = $data.windowVer + 1`, MANY times in a row — well BEFORE
// refineRowEstimate()'s own fold-then-re-feed (which runs only AFTER that loop finishes) has
// folded those same measurements into the running mean or re-fed the converged estimate into
// virtual-core via setOptions(). A live trace of this exact sequence (instrumented resizeItem/
// getMeasurements calls, DataTableColumnVirtualDemo, autoMeasure on) showed Vue batching 3
// resizeItem calls before its ONE downstream re-render reads getMeasurements() — already
// reflecting the fully-folded, re-fed state — versus Solid re-running its padTop()/padBottom()
// effects SYNCHRONOUSLY and IMMEDIATELY on EVERY individual windowVer write (11 interleaved
// resize-then-immediate-recompute pairs, each recompute happening mid-sweep, before
// refineRowEstimate() had run even once). React/Vue/Svelte/Angular/Lit all batch their own
// reactivity to at least a microtask boundary, so their downstream reads land AFTER the whole
// synchronous burst (measurement sweep + fold + re-feed) completes — accidentally correct, not
// correct by construction. Solid does not auto-batch a signal write made from outside a
// Solid-owned event/effect context, so it is the one target where the mid-burst TORN read is
// externally observable. Because `setOptions()` + `_willUpdate()` alone do NOT invalidate
// virtual-core's own `getMeasurements()` memo (keyed on itemSizeCacheVersion /
// getMeasurementOptions() — never on the estimateSize FUNCTION reference itself; confirmed from
// the same installed source, dist/esm/index.js:585-587,624), Solid's LAST such mid-sweep
// recompute is also the LAST time getMeasurements() is ever invoked for that sweep once no
// further row happens to differ from its cache — so the DOM stays frozen on that stale,
// pre-fold/pre-re-feed snapshot indefinitely, even though the accumulator itself has already
// converged correctly (T-87-07's own confirmed finding).
//
// FIX: coalesce every requester of a windowVer bump — virtual-core's own onChange AND
// refineRowEstimate()'s explicit re-feed bump — behind ONE microtask-deferred write, the SAME
// idiom scheduleRemeasure() already uses in virtualization.rzts. This makes the render happen
// EXACTLY ONCE, strictly AFTER the entire synchronous burst (including refineRowEstimate()'s
// fold + re-feed) on EVERY target, by construction rather than by incidental host-framework
// batching. Scoped to the ROW axis only: colVirtualizer never calls resizeItem() at all (D-06 —
// column widths come from table-core's getSize() oracle, never measured from the DOM), so
// columnVirtualizerOptions()'s onChange cannot hit this burst class and is left untouched.
function bumpWindowVer(): void {
if (windowVerBumpPending.current) return;
windowVerBumpPending.current = true;
const flush = () => {
windowVerBumpPending.current = false;
setWindowVer(prev => prev + 1);
};
// Mirrors scheduleRemeasure()'s own defensive queueMicrotask-with-setTimeout-fallback
// (virtualization.rzts) for environments where queueMicrotask is unavailable.
if (typeof queueMicrotask !== 'undefined') queueMicrotask(flush);else setTimeout(flush, 0);
}
// ESTIMATE_REFEED_DELTA_PX (D-15): the hysteresis threshold gating a re-feed into
// virtual-core. Without it, a mean nudging by a fraction of a pixel on every fold would
// re-feed on every window commit — the T-87-07-01 DoS control, paired with virtual-core's
// own measureElement/resizeItem idempotence (see refineRowEstimate() below).
// estimateRowSize(i) (D-15/D-17): the estimateSize() resolver. MUST check !autoMeasureOn()
// FIRST so the off path touches zero accumulator state and returns $props.estimateRowHeight
// verbatim (D-17's byte-behavioral no-op). The zero-measurements case (first paint,
// regardless of autoMeasure) still returns the seed — the very first render has nothing
// measured yet either way (D-15).
function estimateRowSize(i: number): number {
if (!autoMeasureOn()) return props.estimateRowHeight;
if (measuredRowCount.current === 0) return props.estimateRowHeight;
return Math.round(measuredRowTotal.current / measuredRowCount.current);
}
// foldMeasuredRow(index, height): fold ONE measured row's height into the running-mean
// accumulator, UPDATING (not double-adding) an already-folded index (T-87-07-03).
// 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's windowVer write is routed through
// bumpWindowVer() (87-10) rather than a raw `$data.x = $data.x + 1` — resizeItem() can call
// this onChange MANY times in a single synchronous sweep (once per row whose real measured
// size differs from its cache, e.g. every never-before-measured row in the FIRST window),
// and coalescing those into one microtask-deferred write is what keeps every target's render
// landing strictly AFTER the whole sweep (see bumpWindowVer()'s own comment for the confirmed
// Solid-specific rendering gap this closes). The React emitter still lowers the underlying
// `$data.windowVer = $data.windowVer + 1` to functional setState — correct even deferred to a
// microtask, exactly as it was correct from a mount closure before.
function virtualizerOptions(): any {
return {
count: windowSource().length,
getScrollElement: () => gridScrollEl.current,
estimateSize: (i: any) => estimateRowSize(i),
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
bumpWindowVer();
// 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.current) {
// Rows OFF (Phase 87 D-04: this now includes the colsWindowed()-only path, since the
// wrapper template is entered whenever isWindowed(), not just rowsWindowed() — the row
// virtualizer is never constructed when only the column axis is windowed, D-04) → the FULL
// set, with a SYNTHETIC `vi.index` set to each row's array position (matching rowIndexOf's
// own `$data.rows.indexOf(row)` semantics exactly, since $data.rows IS windowSource()'s
// output here). Every windowed body binding reads wr.vi.index (data-row, aria-rowindex,
// colIndexOf, isEditing, the fill handle) — a bare `null` there is a hard crash the moment
// this branch is reached with the wrapper mounted, which colsWindowed()-only now does.
// Row-virtual ON but the virtualizer is not yet constructed (pre-$onMount first paint) →
// render NOTHING so the template never dereferences a not-yet-real `vi`; the rows appear on
// the first onChange after _didMount.
if (!rowsWindowed()) {
const rowList = rows || [];
return rowList.map((r: any, i: any) => ({
vi: {
index: i
},
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;
}
// 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 (!rowsWindowed() || !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 (!rowsWindowed() || !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;
}
// 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 (!rowsWindowed() || !virtualizer.current) return false;
const items = virtualizer.current.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
}
// ══ Phase 87 87-04 — the column-axis analogs of windowedRows()/padTop()/padBottom()/
// rowIsOutsideWindow() above. The column axis has no "row-shaped" identity to carry alongside
// a VirtualItem (a column is not a full-model object the way a row is), so windowedColIndices()
// returns bare ABSOLUTE leaf-column indices; the template resolves each index back to a header/
// cell through the host's own header-group / visibleCellsFor lookups (D-08/D-09). ══
// windowedColIndices(): the ordered array of ABSOLUTE leaf-column indices to render.
// 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.
// Scroll-end pin state (see recordScrollEnd()): whether the USER left the view at the end,
// the option count at that moment, and the last scrollTop already accounted for.
// Non-reactive per-instance flag (Phase 86 R2, plan 86-03, Solid-only): true for
// the duration of an onFocus-triggered open transition (set before the isOpen
// write, cleared in the deferred microtask after). Lets onBlur distinguish a
// blur caused by Solid recreating the anchor's DOM mid-open (skip closing) from
// a genuine user-initiated blur (close normally). See onFocus/onBlur below.
// Non-reactive per-instance flag (combobox-virtual-reactivity phase): set true once
// $onMount has run; read by windowedView() below so the blank-frame fallback (D-4) only
// fires on a genuine RUNTIME flip — a virtual:true-at-mount (never-flipped) consumer's
// first paint stays byte-stable (windowedRows()'s own pre-mount `[]` still applies before
// didMount flips true). Mirrors the same write-in-$onMount/read-elsewhere holder class.
// ---- 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.
//
// Quick 260717-8zb dogfood: re-expressed on the `$memo(fn, keyFn)` primitive.
// `$memo` lowers (core, shared across all 6 targets) to a member-mutated
// fresh-object cache const + a wrapper function — EXACTLY this foCache shape,
// generalized. On React the emitted cache const is stabilized to
// `useMemo(() => ({…}), [])` by the EXISTING collectMutatedInstanceBinders/
// tryWrapMutatedInstanceUseMemo machinery (feedback_react_const_mutinstance_
// not_stabilized) — no per-target $memo code. On the 5 setup-once targets the
// top-level consts persist for the instance lifetime naturally.
//
// keyFn is the SUBSCRIBE-FIRST half (fine-grained Solid <For> / Svelte
// {#each}): it reads ALL FOUR reactive inputs UNCONDITIONALLY — $data.inputText
// even when disableFilter is true (mirrors windowing.rzts windowedRows
// void-touch discipline) and $props.groups even when $props.virtual (so a
// groups change while windowed still invalidates the cache once virtual
// toggles off) — evaluated BEFORE $memo's cache-hit check, so the r-for
// accessor subscribes to them on every eval. 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 / groups-ref) change.
//
// fn is the MISS path (unchanged from the hand-rolled foCache): run the
// filter, then (native option grouping, combobox-native-groups) a
// NON-VIRTUAL-ONLY stable re-partition into group-visual order, then map to
// wrapper rows.
const filteredOptionsCache = useMemo(() => ({
keys: null as any[] | null,
val: null as any
}), []);
const filteredOptions = useCallback(() => {
const __rozieMemoKey = (() => {
const opts = Array.isArray(props.options) ? props.options : [];
const df = !!props.disableFilter;
const q = String(inputText == null ? '' : inputText);
const groupsProp = props.groups;
return [opts, q, df, groupsProp];
})();
const __rozieMemoPrev = filteredOptionsCache.keys;
if (__rozieMemoPrev !== null && __rozieMemoPrev.length === __rozieMemoKey.length && __rozieMemoKey.every((v: any, i: any) => v === __rozieMemoPrev[i])) {
return filteredOptionsCache.val;
}
const __rozieMemoVal = (() => {
const opts = Array.isArray(props.options) ? props.options : [];
const df = !!props.disableFilter;
const q = String(inputText == null ? '' : inputText);
const groupsProp = props.groups;
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
// Gated to !$props.virtual (groups×virtual is deferred/unsupported per design) AND to
// $props.groups being a NON-EMPTY array — an explicit author opt-in. This is deliberately
// NOT just "!$props.virtual" (groupOptions() would otherwise also fire whenever any raw
// option happens to carry a `.group` field, even with `groups` absent — a real collision
// discovered against command-palette's CommandItem.group, which is a PRE-EXISTING,
// unrelated per-row-badge field, not an opt-in to combobox's native grouping. The design's
// "Empty/absent `groups` ⇒ today's flat behavior, byte-identical" contract is about the
// `groups` PROP only — never inferred from incidental option shape.
if (!props.virtual && Array.isArray(groupsProp) && groupsProp.length > 0) {
const partition = groupOptions(list, groupsProp, (o: any) => o && o.group != null ? String(o.group) : null);
list = partition.ordered;
}
// `_i` is assigned over the (now group-ordered) list, so the flat keyboard model
// (activeIndex/aria-activedescendant/nextEnabled) walks visual order unchanged.
// `group` carries the wrapper's normalized group id for groupBlocks() below.
return list.map((o: any, i: any) => ({
value: valueOf(o),
label: labelOf(o),
disabled: disabledOf(o),
_i: i,
id: valueOf(o),
option: o,
group: o && o.group != null ? String(o.group) : null
}));
})();
filteredOptionsCache.keys = __rozieMemoKey;
filteredOptionsCache.val = __rozieMemoVal;
return __rozieMemoVal;
}, [disabledOf, inputText, labelOf, props.disableFilter, props.groups, props.options, props.virtual, valueOf]);
// 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 = useCallback(() => filteredOptions(), [filteredOptions]);
// windowedView() (combobox-virtual-reactivity, VIRT-FALLBACK): the combobox-side
// blank-frame fallback for the mid-flip frame. While `virtual` is on but the virtualizer
// has not yet (re)attached (didMount-gated, so the never-flipped virtual:true-at-mount
// first paint is untouched — windowedRows()'s own pre-mount `[]` still governs it),
// render the UN-WINDOWED full windowSource() slice mapped to the `{ vi: { index }, row }`
// shape the windowed template consumes (`wr.vi.index` resolves to the wrapper's own `_i`,
// since windowSource() IS the filtered/indexed list navRows()/activeIndex already walk).
// Once the virtualizer is built, delegates to windowedRows() UNCHANGED — byte-identical
// to today's steady windowed state. Entirely combobox-side: @rozie-ui/headless-core/
// windowing.rzts is untouched, preserving data-table's B13 A==B byte-identity + its
// empty-diff regen.
function windowedView() {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}) — touch windowVer at the
// TOP, mirroring windowedRows()'s own subscribe-first discipline (windowing.rzts), so
// the accessor re-runs when buildVirtualizer()/kickWindow() bump windowVer once the
// virtualizer attaches — the transition OUT of this fallback and into windowedRows().
void windowVer;
if (props.virtual && !virtualizer.current && didMount.current) {
return windowSource().map((row: any) => ({
vi: {
index: row._i
},
row
}));
}
return windowedRows();
}
// ---- native option grouping render helpers (combobox-native-groups) ---------------
// groupBlocks(): re-partition the ALREADY group-ordered filteredOptions() wrappers into
// CONTIGUOUS runs by wrapper.group (trivial + guarantees `_i` alignment, since `ordered`
// from groupOptions() is already group-contiguous). Attaches each run's `{ id, label }`
// from $props.groups (fallback label = the group id itself). Plain function — never
// $computed (mirrors filteredOptions()'s convention). Non-virtual only (isGrouped() below
// already gates the template branch that calls this).
function groupBlocks() {
const wrappers = filteredOptions();
const groupsProp = Array.isArray(props.groups) ? props.groups : [];
const labelFor = (gid: any) => {
const found = groupsProp.find((g: any) => g && g.id === gid);
return found ? found.label : gid;
};
const blocks = [];
let lastGid;
for (let i = 0; i < wrappers.length; i++) {
const w = wrappers[i];
if (i === 0 || w.group !== lastGid) {
blocks.push({
group: w.group == null ? null : {
id: w.group,
label: labelFor(w.group)
},
items: [w]
});
} else {
blocks[blocks.length - 1].items.push(w);
}
lastGid = w.group;
}
return blocks;
}
// isGrouped(): the grouped-vs-flat template branch selector. Grouping is active
// (non-virtual only) SOLELY when the author explicitly set a non-empty `groups` prop —
// deliberately NOT "OR any option carries a group" (a real collision discovered against
// command-palette's pre-existing CommandItem.group per-row-badge field; see the
// filteredOptions() comment above). Mirrors that same non-empty-`groups` gate exactly, so
// isGrouped() and the filteredOptions() partition never disagree about which branch is active.
function isGrouped() {
return !props.virtual && Array.isArray(props.groups) && props.groups.length > 0;
}
// ---- per-group result cap + expand-in-place "+N more" (combobox-group-cap) --------
// capNum(): coerce $props.groupCap to a whole, positive cap; anything else (NaN,
// negative, absent) degrades to 0 (uncapped). Plain function — never $computed.
function capNum() {
const n = Number(props.groupCap);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
}
// isCapped(): the capped-render branch selector. isGrouped() already gates non-
// virtual + non-empty `groups`, so the cap is automatically gated OUT of the
// virtual and ungrouped paths.
function isCapped() {
return isGrouped() && capNum() > 0;
}
// gkey(gid): normalize a group id (possibly null, for the leading ungrouped
// section) into an expandedGroups map key.
function gkey(gid: any) {
return gid == null ? '__ungrouped__' : String(gid);
}
// isExpanded(gid): whether the group has been expanded via its "+N more" row.
function isExpanded(gid: any) {
return !!(expandedGroups && expandedGroups[gkey(gid)]);
}
// expandGroup(gid): replace $data.expandedGroups IMMUTABLY (load-bearing for
// React re-render — feedback_react_const_mutinstance_not_stabilized / the
// graph-writeback immutability rule).
function expandGroup(gid: any) {
setExpandedGroups(prev => Object.assign({}, prev, {
[gkey(gid)]: true
}));
}
// cappedBlocks(): the visible-block model for the capped render — groupBlocks()
// re-sliced to `capNum()` per group (unless expanded or non-overflowing), with a
// trailing "+N more" row appended to any still-capped block. Re-indexes `_i` as a
// running counter over the WHOLE visible+more sequence so option ids/aria-
// activedescendant stay contiguous and never disagree with navRows() below.
function cappedBlocks() {
const blocks = groupBlocks();
const cap = capNum();
let running = 0;
const out = [];
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
const gid = blk.group ? blk.group.id : null;
const showAll = isExpanded(gid) || blk.items.length <= cap;
const visibleSrc = showAll ? blk.items : blk.items.slice(0, cap);
const items = [];
for (let vi = 0; vi < visibleSrc.length; vi++) {
items.push(Object.assign({}, visibleSrc[vi], {
_i: running
}));
running++;
}
let more: any = null;
if (!showAll) {
more = {
isMore: true,
group: gid,
hidden: blk.items.length - cap,
disabled: false,
_i: running,
expand: () => expandGroup(gid)
};
running++;
}
out.push({
group: blk.group,
items,
more
});
}
return out;
}
// ---- creatable mode (Phase 86 R3, D-17..D-20) ---------------------------
// normalizedQuery(): trimmed + lower-cased query — reuses the SAME case-fold
// filteredOptions() already applies above, but for an EXACT-EQUALITY
// comparison, never a substring search, and with NO Unicode normalization
// (R3 locked: a composition-form difference must NOT be treated as a match).
function normalizedQuery() {
return String(inputText == null ? '' : inputText).trim().toLowerCase();
}
// queryMatchesOption(nq): whether the (already-normalized) query is an exact,
// case-insensitive, trimmed match of some option's label.
function queryMatchesOption(nq: any) {
const opts = Array.isArray(props.options) ? props.options : [];
return opts.some((o: any) => String(labelOf(o)).trim().toLowerCase() === nq);
}
// isCreatableQuery(): the create-row visibility gate (also gates the `#empty`
// -> `#create` swap, D-19). `creatable` must be set, the normalized query
// must be non-empty (an empty/whitespace-only query never offers create —
// `#empty` keeps its job there), and no option's normalized label may equal
// it exactly.
function isCreatableQuery() {
if (!props.creatable) return false;
const nq = normalizedQuery();
if (!nq) return false;
return !queryMatchesOption(nq);
}
// createRowAt(baseCount): the synthetic, non-option `role="option"` create
// row (D-17) — mirrors the `groupMore` "+N more" row shape exactly (a real
// id, arrow-reachable, commits through the SAME selectOption() dispatch
// without writing the model). Each render branch passes ITS OWN flattened
// pre-create-row row count (`baseCount`) as the running index, exactly as
// `cappedBlocks()` already re-indexes `_i` across options + the more row —
// so ids / aria-activedescendant / navRows() can never disagree.
const createRowAt = useCallback((baseCount: any) => ({
isCreate: true,
_i: baseCount,
disabled: false
}), []);
// cappedRowCount(): the total navigable row count cappedBlocks() flattens to
// (visible items + more-rows, across every block) — the running index the
// capped branch's own create row (below) must continue from. Mirrors
// cappedBlocks()'s own `running` counter without re-deriving `_i` per item.
const cappedRowCount = useCallback(() => {
const blocks = cappedBlocks();
let n = 0;
for (let bi = 0; bi < blocks.length; bi++) {
n += blocks[bi].items.length;
if (blocks[bi].more) n++;
}
return n;
}, [cappedBlocks]);
// navRows(): the SINGLE keyboard/aria source of truth. Returns the EXACT
// filteredOptions() reference when not capped and not creatable (byte-
// identical-off — untouched virtual/ungrouped keyboard path); flattens
// cappedBlocks() into visible items + more-rows, in order, when capped.
// Appends the create row, AFTER the full flattened visible(+more) sequence,
// whenever isCreatableQuery() — R3's locked "renders last, after all options
// and group sections" is a positional fact here, not a per-branch special case.
function navRows() {
if (!isCapped()) {
const base = filteredOptions();
if (!isCreatableQuery()) return base;
return base.concat([createRowAt(base.length)]);
}
const out = [];
const blocks = cappedBlocks();
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
for (let ii = 0; ii < blk.items.length; ii++) out.push(blk.items[ii]);
if (blk.more) out.push(blk.more);
}
if (isCreatableQuery()) out.push(createRowAt(out.length));
return out;
}
// 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;
}
// D-05 windowing.rzts host-contract one-liner (Phase 87 87-02). rowsWindowed() preserves
// today's EXACT truthiness (byte-behavior-identical) — it is the REQUIRED symbol
// windowing.rzts calls in place of a bare `$props.virtual` read.
//
// GAP-CLOSURE 87-16 (WR-02): the column-axis host-contract symbols (`colVirtualizer`,
// `colsWindowed()`, `columnCount()`, `columnSize()`, `forcedColumns()`) that 87-02 added
// alongside this were REMOVED here — they were dead code shipped on a mistaken premise
// about the compiler's tree-shaking BFS. Combobox imports only `{ virtualItemKey,
// virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow }`
// from windowing.rzts; none of those functions' bodies reference the column-axis symbols
// (only `columnVirtualizerOptions()`/`windowedColIndices()`/`colPadLeft()`/`colPadRight()`/
// `colIsOutsideWindow()` do, and Combobox never imports any of those), so
// `inlineScriptPartials()`'s BFS never needed them to exist. See 87-REVIEW.md WR-02 /
// 87-16-SUMMARY.md for the verification trail.
function rowsWindowed() {
return !!props.virtual;
}
// autoMeasureOn() (Phase 87 87-07, D-18/D-20): the content-driven-estimate host-contract
// gate. Combobox never lights this branch — a permanent `false` keeps windowing.rzts's
// estimateRowSize()/refineRowEstimate() accumulator dead code here. RETAINED (unlike the
// column-axis symbols above): `virtualizerOptions()` — which Combobox DOES import and call
// — wires `estimateSize: (i) => estimateRowSize(i)`, and `estimateRowSize()` calls
// `autoMeasureOn()` as its first line. This one IS reachable through the import graph.
function autoMeasureOn(): boolean {
return false;
}
// Keep $data.rows === windowSource() so the windowing math indexes the live filtered set.
const syncRows = useCallback(() => {
setRows(windowSource());
}, [windowSource]);
// SCROLL-END PIN (the data-table D-19 twin, shared shape with Listbox): keep a user who
// scrolled to the END of a variable-height list at the end while the options in view measure
// taller than their estimate. The view is judged on the DOM, and only at a move the USER
// made — a move is virtual-core's own when it still holds an unreconciled scroll adjustment
// (scrollAdjustments !== 0): its above-viewport compensation writes an ABSOLUTE scrollTop
// computed from its last-observed (stale) offset, so it pulls the view back up from the end
// and must neither clear the pin nor be mistaken for the user leaving the end. That position
// is remembered so the scroll event that later reports it is not read as a user move either.
// (Judging on virtual-core's MODEL, as the data-table host does, fails here: its total grows
// with every option measured in the ResizeObserver batch while its offset stays at the stale
// value, so the pin was cleared mid-batch — every target ended 10-126px short, measured.)
function recordScrollEnd() {
if (!virtualizer.current || !gridScrollEl.current || virtualizer.current.scrollState) return;
const top: number = gridScrollEl.current.scrollTop;
if (top === scrollEndPinnedTop.current) return;
scrollEndPinnedTop.current = top;
if (virtualizer.current.scrollAdjustments !== 0) return;
// Only a list that actually overflows has an end to hold: while the window has not painted
// yet (or the list is closed), scrollHeight <= clientHeight reads as "at the end" and a pin
// recorded then would jump the freshly opened list to the bottom.
const sh = gridScrollEl.current.scrollHeight;
const ch = gridScrollEl.current.clientHeight;
scrollEndPinned.current = ch > 0 && sh - ch > 1 && sh - top - ch <= 1;
scrollEndPinnedCount.current = windowSource().length;
}
// Re-apply the pin after the framework has committed the window (called from the rAF pass):
// the real maximum is known only then. Not while a programmatic scroll (scrollToIndex) is in
// flight, and not when the option count changed since the user reached the end (a new query
// or appended options must not be auto-followed).
function keepScrollEnd() {
if (!scrollEndPinned.current || !virtualizer.current || !gridScrollEl.current || virtualizer.current.scrollState) return;
if (windowSource().length !== scrollEndPinnedCount.current) return;
const maxTop: number = gridScrollEl.current.scrollHeight - gridScrollEl.current.clientHeight;
if (maxTop - gridScrollEl.current.scrollTop > 1) {
gridScrollEl.current.scrollTop = maxTop;
scrollEndPinnedTop.current = gridScrollEl.current.scrollTop;
}
}
// 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() {
recordScrollEnd();
if (remeasurePending.current) return;
remeasurePending.current = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
// N-05 (quick 260923-rrr): key the rAF pass on the OUTCOME. React and Angular commit the
// recycled window AFTER the first rAF, so one pass measured the OLD options and the new ones
// waited for virtual-core's 150ms scrolling-ended tick — with variable-height options the late
// above-viewport adjustment then moved the whole list (measured). Re-run next frame until the
// committed options cover the virtualizer's window, bounded (the data-table host twin).
let rafAttempts = 0;
const rafPass = () => {
const covered = remeasureWindow();
rafAttempts = rafAttempts + 1;
if (!covered && rafAttempts < 10 && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(rafPass);
return;
}
keepScrollEnd();
remeasurePending.current = false;
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending.current = 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.current || !gridScrollEl.current) return true;
if (virtualizer.current.scrollState) return true;
const els = gridScrollEl.current.querySelectorAll('.rozie-combobox-option[data-index]');
const rendered = new Set();
for (const el of els as any) {
virtualizer.current.measureElement(el);
rendered.add(el.getAttribute('data-index'));
}
// N-05: false while the framework has not yet committed the recycled window.
const items = virtualizer.current.getVirtualItems();
for (let i = 0; i < items.length; i++) {
if (!rendered.has(String(items[i].index))) return false;
}
return true;
}
// Keep the active option visible inside the popup. When windowing, route through the
// virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window scrolls
// into view (the windowed-arrow-nav seam). When NOT windowing, resolve the active
// option element directly (a within-own-shadow query, Lit-safe) and scrollIntoView it
// with 'nearest' block alignment — a plain long list taller than the popup's
// max-height must also keep the active option visible during arrow navigation.
function scrollActiveIntoView() {
if (!props.virtual && isOpen && activeIndex >= 0) {
const list = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rozie-combobox-list') : null;
const opt = list ? list.querySelector('#' + optId(activeIndex)) : null;
if (opt) opt.scrollIntoView({
block: 'nearest'
});
return;
}
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();
}
// idRoot(): the id base — the `idBase` prop, else the per-instance id generated
// in $onMount (`autoId`), else the pre-mount fallback. Generated after mount (not
// during setup) so a server render and the hydrating client agree.
function idRoot() {
return props.idBase || autoId || 'rozie-combobox';
}
function optId(i: any) {
return idRoot() + '-opt-' + i;
}
function listId() {
return idRoot() + '-list';
}
// popupVisible() (hideEmpty, COMBOBOX-SPEC item 4): whether the popup is actually
// SHOWN — open AND (unless `hideEmpty`) something to render. With `hideEmpty` an
// open popup with no option rows AND no create row counts as hidden: the list
// branches do not render, aria-expanded reports false, and Escape is left to the
// host (B4). Without `hideEmpty` this is exactly `$data.isOpen` (byte-identical-off).
function popupVisible() {
if (!isOpen) return false;
if (!props.hideEmpty) return true;
return navRows().length > 0;
}
// The active option's id for aria-activedescendant (null when none).
function activeId() {
const list = navRows();
if (popupVisible() && activeIndex >= 0 && list[activeIndex]) return optId(activeIndex);
return null;
}
// activeOption() (handle verb, COMBOBOX-SPEC item 8): the highlighted RAW source
// option, or null (nothing highlighted, the popup is hidden, or the highlighted
// row is a synthetic "+N more" / create row).
function activeOption() {
const list = navRows();
const ai = activeIndex;
if (!popupVisible() || ai < 0) return null;
const row = list[ai];
if (!row || row.isMore || row.isCreate) return null;
return row.option === undefined ? null : row.option;
}
// 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;
}
// ---- multi-select membership + effective-default helpers (Phase 86 R1) -----
// Ported from @rozie-ui/headless-core/listCore.rzts's select()/isSelected()
// algorithm (also shipped, verbatim, via @rozie-ui/listbox) — PORTED, not
// imported: combobox's own open/active/query state machine is deliberately
// host-local (see the header comment above), and listCore.rzts is also
// consumed by the release-ignored listbox family, so pulling this into the
// shared partial would put listbox's frozen leaves back in scope.
//
// selectedValues(): the current selection as a de-duplicated array, tolerant
// of a null/undefined model. De-duplicates the MODEL array itself (not just
// `options`) so a re-normalized selection never reports the same value twice
// even if the model ever ends up holding a duplicate.
function selectedValues() {
const cur = value;
const arr = Array.isArray(cur) ? cur : [];
return Array.from(new Set(arr));
}
// isRowSelected(row): array membership under `multiple`, strict equality
// otherwise. Replaces every raw `opt.value === $props.value` / `wr.row.value
// === $props.value` template comparison (task 2) so all four render branches
// share exactly ONE membership check and can never disagree.
function isRowSelected(row: any) {
if (!row) return false;
if (props.multiple) return selectedValues().indexOf(row.value) !== -1;
return row.value === value;
}
// effectiveCloseOnSelect(): resolves the `closeOnSelect` sentinel (see the
// prop's own doc comment above for why the prop's default is `null`, not a
// literal `true`). Unset ⇒ `true` in single-select (today's default,
// unchanged), `false` under `multiple`; an explicit `true`/`false` from the
// consumer always wins in either mode. Every existing `closeOnSelect` read
// routes through this helper so the four render branches cannot disagree.
function effectiveCloseOnSelect() {
const v = props.closeOnSelect;
if (v === true || v === false) return v;
return !props.multiple;
}
// chipsInline() (chipLayout, COMBOBOX-SPEC item 2): chips + input on one
// wrapping row — only meaningful under `multiple`.
function chipsInline() {
return !!props.multiple && props.chipLayout === 'inline';
}
// ---- chip rail (Phase 86 R1, plan 86-05, D-13/D-16/D-18) ---------------
// chipRows(): selectedValues() (already de-duplicated — see above) mapped to
// chip-rail display rows. Each row carries the raw source `option` when it is
// still present in `options` (mirroring how filteredOptions() attaches the raw
// option to every wrapper row), or a raw-value fallback label when the option
// has disappeared from an asynchronously swapped `options` array — the locked
// R1 concurrency edge: an orphan chip persists, labelled by its raw value,
// rather than vanishing. `value` array order IS chip display order (R1
// locked); selectedValues() already preserves it.
function chipRows() {
const opts = Array.isArray(props.options) ? props.options : [];
return selectedValues().map((v: any) => {
const found = opts.find((o: any) => valueOf(o) === v);
return found ? {
value: v,
label: labelOf(found),
option: found
} : {
value: v,
label: String(v),
option: null
};
});
}
// chipRemoveLabel(row): the aria-label naming what a chip's remove control removes.
function chipRemoveLabel(row: any) {
return 'Remove ' + String(row.label);
}
// removeChipValue(v) is defined AFTER selectOption() below (not here) — React's
// emitter derives each `useCallback`'s static dependency array from the
// helpers its body calls, and `removeChipValue` calls `selectOption`. Declaring
// it before `selectOption`'s own `const` would put `selectOption` in
// `removeChipValue`'s deps array ahead of its OWN initializer in the SAME
// module scope — a real same-render TDZ (`ReferenceError` at runtime on
// React, TS2448 "used before its declaration" at typecheck). Source order
// here IS emission order for these plain top-level consts, so
// `removeChipValue` must textually follow `selectOption`.
// ---- 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`). `effectiveCloseOnSelect()` gates the popup close.
const { onChange: _rozieProp_onChange, onCreate: _rozieProp_onCreate } = props;
const selectOption = useCallback((opt: any) => {
if (!opt) return;
if (opt.isMore) {
expandGroup(opt.group);
setActiveIndex(opt._i);
return;
}
if (opt.isCreate) {
// Read locals before any write (ROZ138 idiom).
const q = inputText;
const nq = normalizedQuery();
// The double-commit latch (D-17/D-20): a second commit of the SAME
// normalized query — whether a rapid double gesture, or the async
// round-trip window before the consumer's `options` update lands — is a
// no-op. An empty/whitespace normalized query never emits either (the
// row should not even be reachable then, since isCreatableQuery() gates
// it, but this guard is cheap insurance against a stale reference).
if (!nq || nq === createdQuery) return;
setCreatedQuery(nq);
_rozieProp_onCreate && _rozieProp_onCreate({
query: q
});
// D-20: after `create` fires, local UI state behaves like a pick — the
// effective close-on-select applies, and the query clears in `multiple`
// mode (ready for the next entry) and is left alone in single mode (the
// consumer's async add flows back through the ordinary `value` watch).
// `value` itself is untouched — R3 locked.
if (effectiveCloseOnSelect()) setIsOpen(false);
if (props.multiple) clearQuery(null);
setActiveIndex(-1);
return;
}
if (opt.disabled) return;
if (props.multiple) {
// Capture whether the value was already present BEFORE the toggle — this
// local is what feeds the `selected` field on the `change` payload (D-15).
const cur = selectedValues();
const wasSelected = cur.indexOf(opt.value) !== -1;
// Fresh array on every commit — in-place mutation (.push/.splice) is
// silently dropped by the React/Solid/Lit/Angular change detectors.
const next = wasSelected ? cur.filter((v: any) => v !== opt.value) : [...cur, opt.value];
setValue(next);
// D-14: clear the query on pick under `multiple` (not the option's label)
// so Backspace-removes-last stays reachable immediately after a pick.
// `opt.isRemoval` (set only by removeChipValue() below) skips this —
// removing a chip is not a pick, and clobbering whatever the user was
// mid-typing in the search box is a separate, unrelated data loss.
if (!opt.isRemoval) clearQuery(null);
if (effectiveCloseOnSelect()) setIsOpen(false);
setActiveIndex(-1);
_rozieProp_onChange && _rozieProp_onChange({
value: next,
option: opt.option,
selected: !wasSelected
});
return;
}
setValue(opt.value);
setInputText(String(opt.label));
if (effectiveCloseOnSelect()) setIsOpen(false);
setActiveIndex(-1);
// D-15: `selected` is additive and always `true` in single-select.
_rozieProp_onChange && _rozieProp_onChange({
value: opt.value,
option: opt.option,
selected: true
});
}, [_rozieProp_onChange, _rozieProp_onCreate, clearQuery, createdQuery, effectiveCloseOnSelect, expandGroup, inputText, normalizedQuery, props.multiple, selectedValues, setValue]);
// removeChipValue(v): routes chip removal through the EXACT SAME toggle path
// selectOption() uses for a re-select — a synthetic wrapper row is enough,
// since the `multiple` branch above only reads `opt.value`/`opt.option`/
// `opt.disabled`/`opt.isMore` — so removal and toggle-off can never diverge
// into different payload shapes. Declared here, after selectOption(), not
// alongside chipRows()/chipRemoveLabel() above — see the comment there.
function removeChipValue(v: any) {
const opts = Array.isArray(props.options) ? props.options : [];
const found = opts.find((o: any) => valueOf(o) === v);
// isRemoval: true tells selectOption()'s `multiple` branch this is a
// removal, not a pick — see the D-14 comment there.
selectOption({
value: v,
option: found || null,
isRemoval: true
});
}
// onChipRemovePointerDown() (quick-260903-0s1, E1 audit finding): the POINTER
// half of the chip remove control's split binding. Deliberately empty —
// the `.prevent` modifier this is bound to (mousedown) is its ENTIRE payload:
// preventDefault on mousedown suppresses the native focus shift, which is
// what keeps the input focused, keeps onBlur() from firing, and therefore
// keeps the popup open (the CR-02 hazard commit `d02a145ef` closed). The
// removal deliberately does NOT live here: preventDefault on mousedown does
// NOT suppress the click that follows it, so a handler bound to BOTH events
// would remove the chip twice per pointer press. See onChipRemoveActivate()
// below for where the removal actually happens.
const onChipRemovePointerDown = useCallback(() => {}, []);
// onNativeInputChange() (release-0.8.0): the `.stop` on the input's native
// `change` is its whole payload — the native event bubbles out of the inner
// <input> on blur after an edit, and on Angular (no shadow boundary) a consumer
// `(change)` binding on <rozie-combobox> would receive that DOM Event as well as
// the component's own `change` output (the same collision popover's audit B6
// removed). Stopping it keeps `change` meaning only the component event.
const onNativeInputChange = useCallback(() => {}, []);
// onChipRemoveActivate(v) (quick-260903-0s1, E1 audit finding): the CLICK half
// of the split binding — the actual removal. `click` is the one event every
// activation path produces: a real pointer press (mousedown+click), Enter or
// Space on the focused button (native <button> behavior fires `click`, never
// `keydown`-observable-as-such), AND a screen reader's synthesized activation
// (which emits `click` with no preceding `mousedown` at all — the E1 defect
// this fixes). Binding removal to `click` alone covers all three with exactly
// one removal per activation.
//
// Keyboard/AT activation puts DOM focus ON the button, which this removal
// then unmounts — without an explicit refocus, focus would fall to
// `document.body`. Restore it using the EXACT idiom onFocus() above already
// uses (proven on all six targets): a queued microtask that refocuses
// `$refs.inputEl` only when it exists and is not already `document.activeElement`.
// That activeElement guard is what makes this a strict no-op on the pointer
// path — a pointer press never moves focus off the input in the first place
// (onChipRemovePointerDown's preventDefault sees to that), so this refocus
// never re-enters onFocus() and never re-selects the in-progress query.
// $refs is safe here for the same reason it is safe everywhere else in this
// file: this is a post-mount event handler, not module-init code.
//
// `.stop` on the template's `@click` binding (real-browser VR finding,
// quick-260903-0s1): on Solid and Svelte specifically — the two targets whose
// reactivity applies a DOM mutation SYNCHRONOUSLY, inside the very handler
// that triggered it, rather than batched to a microtask like the other four
// — removing this chip's own `<li>` mid-click detaches the click event's
// `target` from the document BEFORE the event finishes bubbling. Popover's
// own document-level `@click.outside($refs.anchorEl,$refs.floatingEl)`
// dismiss listener (Popover.rozie) then evaluates `anchorEl.contains(target)`
// against the NOW-DETACHED target, which is unconditionally `false` for any
// detached node — misreading this internal removal as an outside click and
// closing the popup. `.stop` (stopPropagation) keeps this click from ever
// reaching that document listener, exactly like the sibling `@mousedown.stop`
// pattern command-palette's own action-menu-affordance row already uses to
// keep an inner gesture from bubbling into an ancestor's own listener.
const onChipRemoveActivate = useCallback((v: any) => {
removeChipValue(v);
queueMicrotask(() => {
if (inputEl.current && document.activeElement !== inputEl.current) inputEl.current!.focus();
});
}, [removeChipValue]);
// Reflect the externally-selected value into the input text. D-14: no-ops
// under `multiple` — there is no single label to mirror into the input once
// `value` holds an array, and the query is owned by chip-picking instead.
//
// quick-260903-0s1 (E2 audit finding): routed through the SAME valueOf()/
// labelOf() resolvers every other option read in this file uses
// (filteredOptions(), chipRows(), removeChipValue(), queryMatchesOption()) —
// this was the single site that still read the raw `.value`/`.label`
// properties directly. `optionValue`/`optionLabel` are documented public
// props, and the resolvers additionally carry the primitive-option fallback
// (`String(opt)` when `opt` has no `.label`) — bypassing them blanked the
// input on both the mount path ($onMount → syncQueryToValue()) and the
// external-value path ($watch(() => $props.value, ...) → syncQueryToValue()).
//
// The "not found" guard is on `opt` being neither `undefined` NOR `null`,
// deliberately not on truthiness: with primitive options the found entry IS
// the option, so a legitimate selection of an empty string or a zero would be
// discarded by a truthiness test and re-blank the input — reintroducing the
// bug in a new shape. `Array.prototype.find` returns `undefined` on a miss,
// so that is the correct miss test; the `null` check keeps a `null` option
// from rendering as the literal text "null".
const syncQueryToValue = useCallback(() => {
if (props.multiple) return;
const opts = Array.isArray(props.options) ? props.options : [];
const opt = opts.find((o: any) => valueOf(o) === value);
setInputText(opt === undefined || opt === null ? '' : String(labelOf(opt)));
}, [labelOf, props.multiple, props.options, value, valueOf]);
// ---- free-text commits (COMBOBOX-SPEC items 5-7, multiple only) --------
// delimiterList(): the `delimiters` prop normalized to an array.
function delimiterList() {
return Array.isArray(props.delimiters) ? props.delimiters : [];
}
// splitDelimiters(): the CHARACTER delimiters (everything but 'Enter'/'Tab') —
// the paste split characters.
function splitDelimiters() {
return delimiterList().filter((k: any) => k !== 'Enter' && k !== 'Tab');
}
// freeTextOn(): free-text commits are enabled under `multiple` when a delimiter
// list, a validate function, a splitPaste function or commitOnBlur is supplied.
function freeTextOn() {
return !!props.multiple && (delimiterList().length > 0 || typeof props.validate === 'function' || typeof props.splitPaste === 'function' || !!props.commitOnBlur);
}
// storedText(t): the `validate` gate + normaliser (Tags' shape), for an already
// trimmed, non-empty `t`. Returns the string to store, or null when rejected:
// absent validate ⇒ t; a string return ⇒ that string ('' rejects); any other
// truthy return (`true`) ⇒ t; a falsy return ⇒ rejected.
function storedText(t: any) {
if (typeof props.validate !== 'function') return t;
const r = props.validate(t);
if (!r) return null;
return typeof r === 'string' ? r : t;
}
// commitTexts(texts): append every not-yet-present text to `value` (ONE fresh
// array, ONE model write) and emit one `change` per committed text, each with the
// running array as of that commit. Texts already present are skipped silently.
function commitTexts(texts: any) {
let next = selectedValues();
const committed = [];
const snapshots = [];
for (let i = 0; i < texts.length; i++) {
const t = texts[i];
if (next.indexOf(t) !== -1) continue;
next = next.concat([t]);
committed.push(t);
snapshots.push(next);
}
if (committed.length > 0) setValue(next);
setActiveIndex(-1);
for (let i = 0; i < committed.length; i++) {
props.onChange && props.onChange({
value: snapshots[i],
option: null,
selected: true,
text: committed[i]
});
}
}
// syncInputText(el, text): also write the LIVE input element. Angular compares a
// `[value]` binding against its last RENDERED value: fast typing followed by a
// commit in the same frame (before change detection rendered the typed text)
// leaves query '' === last-rendered '' — no DOM write, the typed text stays.
// Writing the element directly is idempotent on every other target.
function syncInputText(el: any, text: any) {
if (el && typeof el.value === 'string' && el.value !== text) el.value = text;
}
// setTypedText(q, el): the input text changed to `q` — by typing (onInput) or by a
// paste Combobox handled itself (insertAtCaret). Re-arms the create latch, opens
// the list, highlights the first row and emits `search`, exactly as typing does.
function setTypedText(q: any, el: any) {
setInputText(q);
syncInputText(el, q);
// Any input change re-arms the double-commit latch (D-17/D-20) — a
// freshly-typed query is a new gesture, never a repeat of whatever was
// last created.
setCreatedQuery(null);
setIsOpen(true);
setActiveIndex(0);
props.onSearch && props.onSearch({
query: q
});
}
// clearQuery(el): Combobox clearing the input text ITSELF (a pick under
// `multiple`, a create under `multiple`, a free-text commit, clear()). Emits
// `search` with '' so a host tracking the query through `search` never goes
// stale — a free-text commit of an already-selected value fires no `change`,
// so this is the host's only signal. No emit when the text was already empty.
// The live element is consulted too: on React a commit in the same frame as the
// last keystroke still sees the pre-keystroke `inputText` in its closure.
function clearQuery(el: any) {
const had = inputText !== '' || !!(el && typeof el.value === 'string' && el.value !== '');
setInputText('');
syncInputText(el, '');
if (had) props.onSearch && props.onSearch({
query: ''
});
}
// insertAtCaret(el, text): insert `text` into the input at the caret, replacing
// the selection — what an ordinary paste does — and leave the caret after it.
function insertAtCaret(el: any, text: any) {
const cur = el && typeof el.value === 'string' ? el.value : String(inputText);
const start = el && typeof el.selectionStart === 'number' ? el.selectionStart : cur.length;
const end = el && typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
const next = cur.slice(0, start) + text + cur.slice(end);
setTypedText(next, el);
const caret = start + text.length;
if (el && typeof el.setSelectionRange === 'function') el.setSelectionRange(caret, caret);
}
// commitFreeText(raw, el): trim → validate (normalise) → commit + clear the input.
// Returns true when the text was handled (committed, or already present ⇒ just
// cleared); false when empty or rejected — rejected text stays in the input.
function commitFreeText(raw: any, el: any) {
const t = String(raw == null ? '' : raw).trim();
if (!t) return false;
const stored = storedText(t);
if (stored === null) return false;
clearQuery(el);
commitTexts([stored]);
return true;
}
// splitOnDelimiters(text): the built-in paste split — the clipboard text split on
// every CHARACTER delimiter, or null when it contains none (an ordinary paste).
function splitOnDelimiters(text: any) {
const seps = splitDelimiters();
let hasSep = false;
for (let s = 0; s < seps.length; s++) {
if (text.indexOf(seps[s]) !== -1) hasSep = true;
}
if (!hasSep) return null;
let parts = [text];
for (let s = 0; s < seps.length; s++) {
const out = [];
for (let p = 0; p < parts.length; p++) {
const pieces = String(parts[p]).split(seps[s]);
for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
}
parts = out;
}
return parts;
}
// onPaste(e) (item 6): under free-text mode the clipboard text is split — by
// `splitPaste` when supplied, else on the character delimiters — and every
// non-empty trimmed part `validate` accepts is committed (the paste is
// preventDefault-ed). The rejected parts (joined by the first delimiter) are
// inserted at the caret, replacing the selection, as an ordinary paste would be,
// so text typed before the paste is kept. A split of null (splitPaste said "not
// mine", or no delimiter in the text) leaves the paste to the browser.
const { splitPaste: _rozieProp_splitPaste } = props;
const onPaste = useCallback((e: any) => {
if (!freeTextOn()) return;
const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
// typeof checked inline (not via a local flag) so strict TS narrows the call.
const split = typeof _rozieProp_splitPaste === 'function' ? _rozieProp_splitPaste(text) : splitOnDelimiters(text);
if (!Array.isArray(split)) return;
if (e) e.preventDefault();
const accepted = [];
const rejected = [];
for (let i = 0; i < split.length; i++) {
const part = String(split[i] == null ? '' : split[i]).trim();
if (!part) continue;
const stored = storedText(part);
if (stored === null) rejected.push(part);else accepted.push(stored);
}
const seps = splitDelimiters();
const rest = rejected.join(seps.length > 0 ? seps[0] + ' ' : ' ');
if (rest) insertAtCaret(e ? e.target : null, rest);
commitTexts(accepted);
}, [_rozieProp_splitPaste, commitTexts, freeTextOn, insertAtCaret, splitDelimiters, splitOnDelimiters, storedText]);
// ---- input + keyboard handlers -----------------------------------------
const onInput = useCallback((e: any) => {
const q = e && e.target ? e.target.value : '';
setTypedText(q, null);
}, [setTypedText]);
const onFocus = useCallback((e: any) => {
// Phase 86 R2 (plan 86-03), Solid-only reentrancy guard: the input now
// renders inside the composed popover's SCOPED `#anchor` slot
// (`:open="$props.open"` among its params — see the <Popover> template
// comment for why the input moved there). On Solid, a named slot invocation
// with reactive scope params is a plain closure CALL re-run whenever any
// param changes (@rozie/core's documented, intentional Solid
// slot-reactivity design — not a bug to route around at the emitter level):
// the `isOpen` write below changes the `open` param this exact handler is
// responding to, which on Solid SYNCHRONOUSLY recreates the anchor's DOM
// subtree (Solid's JSX has no virtual-DOM diffing to preserve node identity
// across a closure re-invocation) — removing the just-focused `<input>`
// fires a NATIVE blur on it, mid-call-stack, before this function even
// returns. Without the guard below, that blur's own onBlur() would
// immediately set isOpen back to false, and the deferred re-focus further
// down would restart the SAME cycle on the fresh node — an infinite
// recreate/blur/close/refocus loop. `openingInProgress` (below) tells
// onBlur "this blur is a side effect of OUR OWN isOpen write, not the user
// moving focus away" so it can skip closing. The other 5 targets diff their
// scoped-slot re-render and keep the existing, already-focused node — no
// blur ever fires there, so the guard is a no-op for them.
// disableOpenOnFocus (item 3): focus alone never opens the list — typing
// (onInput) and ArrowDown/ArrowUp (onKeydown) still do.
if (props.disableOpenOnFocus) {
if (e && e.target && e.target.select) e.target.select();
return;
}
openingInProgress.current = true;
setIsOpen(true);
// Cleared SYNCHRONOUSLY, immediately after the write — Solid's reactive
// cascade (if any) runs SYNCHRONOUSLY as part of that write, before this
// line executes, so the guard window covers exactly the recreate/blur
// cascade and nothing past it. A deferred (microtask) clear would leave a
// stale `true` window spanning an `await` boundary whenever the re-focus
// below re-enters onFocus, incorrectly suppressing a LATER, genuine blur.
openingInProgress.current = false;
if (e && e.target && e.target.select) e.target.select();
queueMicrotask(() => {
// Re-assert focus onto whatever node is CURRENT — after Solid's
// synchronous signal-write reactivity (if any) has already run and
// `$refs.inputEl` reflects the latest node — recovering focus if it was
// stranded on a since-removed one.
if (inputEl.current && document.activeElement !== inputEl.current) inputEl.current!.focus();
});
}, [props.disableOpenOnFocus]);
// @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.
// While `pinned` (pinOpen(true)), early-return BEFORE the isOpen write — a host
// sub-surface (e.g. command-palette's action flyout) is holding focus and the
// popup must stay open until the host calls pinOpen(false) itself. While
// `openingInProgress` (Solid-only, see onFocus above), early-return too — this
// blur is a side effect of our OWN open-transition recreating the anchor's DOM,
// not the user moving focus elsewhere.
// commitOnBlur: leaving the field commits the typed text through validate (a blur
// into a pinned host sub-surface, or the Solid recreate blur, returned above).
const onBlur = useCallback((e: any) => {
if (pinned) return;
if (openingInProgress.current) return;
setIsOpen(false);
if (props.commitOnBlur && freeTextOn()) {
const el = e ? e.target : null;
commitFreeText(el ? el.value : inputText, el);
}
}, [commitFreeText, freeTextOn, inputText, pinned, props.commitOnBlur]);
const onKeydown = useCallback((e: any) => {
// B10: ignore every key while an IME composition is active — the Enter that
// confirms a composition must never pick, commit or navigate. Read through
// `nativeEvent` when present: React's synthetic keyboard event does not carry
// `isComposing` (every other target hands the native event straight through).
const ne = e && e.nativeEvent ? e.nativeEvent : e;
if (ne && (ne.isComposing || ne.keyCode === 229)) return;
const key = e ? e.key : '';
const list = navRows();
// 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;
const visible = popupVisible();
const liveText = e && e.target ? e.target.value : '';
const highlighted = wasOpen && ai >= 0 && list[ai] ? list[ai] : null;
// Character delimiters (item 5): commit the TYPED text — never the highlighted
// option. 'Enter' / 'Tab' entries are handled in their own branches below.
if (freeTextOn() && key !== 'Enter' && key !== 'Tab' && delimiterList().indexOf(key) !== -1) {
if (e) e.preventDefault();
commitFreeText(liveText, e ? e.target : null);
return;
}
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') {
// B9: Enter with Ctrl / Meta / Alt is left to the host (e.g. a send shortcut).
const modified = !!(e && (e.ctrlKey || e.metaKey || e.altKey));
if (!modified) {
if (highlighted) {
if (e) e.preventDefault();
selectOption(highlighted);
} else if (freeTextOn() && String(liveText).trim()) {
// Free-text mode (item 7): Enter with no highlighted option commits the
// typed text (rejected text stays in the input).
if (e) e.preventDefault();
commitFreeText(liveText, e ? e.target : null);
}
}
} else if (key === 'Tab') {
// selectOnTab (item 8): pick the highlighted option while the popup is
// visible; preventDefault ONLY when it picked. A 'Tab' delimiter commits the
// typed text when nothing was picked. Otherwise Tab moves focus normally.
if (props.selectOnTab && visible && highlighted && !highlighted.disabled) {
if (e) e.preventDefault();
selectOption(highlighted);
} else if (freeTextOn() && delimiterList().indexOf('Tab') !== -1 && String(liveText).trim()) {
if (commitFreeText(liveText, e ? e.target : null) && e) e.preventDefault();
}
} else if (key === 'Escape') {
// B4: only consume Escape when the popup is actually VISIBLE.
if (visible) {
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));
}
} else if (key === 'Backspace') {
// Backspace-removes-last-chip (Tags.rozie precedent, Phase 86 R1 plan
// 86-05): guarded on `multiple` AND the LIVE input value being empty —
// read `e.target.value` directly (Tags' proven idiom), never the mirrored
// `$data.inputText`. A non-empty query falls through to normal text editing —
// nothing here removes a chip while there is text to delete.
if (props.multiple) {
const liveValue = e && e.target ? e.target.value : '';
if (liveValue === '') {
const cur = selectedValues();
if (cur.length > 0) {
if (e) e.preventDefault();
removeChipValue(cur[cur.length - 1]);
}
}
}
}
// Keep the (new) active option in view — routes through the virtualizer when
// windowing, direct scrollIntoView otherwise.
scrollActiveIntoView();
}, [activeIndex, commitFreeText, delimiterList, freeTextOn, isOpen, navRows, nextEnabled, popupVisible, props.multiple, props.selectOnTab, removeChipValue, scrollActiveIntoView, selectOption, selectedValues]);
// ---- 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.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);
}
}
// buildVirtualizer() (combobox-virtual-reactivity, VIRT-BUILD): the SINGLE virtualizer
// construction site — called from $onMount below (mount-time virtual:true) AND from the
// virtual $watch further down (a runtime false→true flip), so the mount path can never
// drift from the flip path. Guarded so a build queued (rAF-deferred by the $watch) that
// fires AFTER a flip-back is a no-op (rapid-flip idempotence), and so calling it twice
// never double-constructs.
const buildVirtualizer = useCallback(() => {
if (!props.virtual || virtualizer.current) return;
// 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. 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.
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);
}, [kickWindow, props.virtual, virtualizerOptions]);
// teardownVirtualizer() (VIRT-TEARDOWN): runs the SAME per-instance cleanup fn
// $onUnmount invokes below, then nulls the instance state + bumps windowVer so the
// windowed template branch (still mounted while $props.virtual — CR-01) re-derives to
// the pre-construction fallback state instead of holding a stale virtualizer. This is
// the true→false ResizeObserver-leak fix: previously ONLY $onUnmount ever called
// virtualizerCleanup, so a runtime flip to non-virtual left the observer live.
function teardownVirtualizer() {
if (virtualizerCleanup.current) virtualizerCleanup.current();
virtualizer.current = null;
virtualizerCleanup.current = null;
gridScrollEl.current = null;
setWindowVer(prev => prev + 1);
}
// nextAutoId(): a page-wide counter shared by every Rozie component instance. It
// lives on globalThis (read through Reflect, which type-checks in the plain-JS and
// the TS script alike) so separately bundled copies of a leaf never hand out the
// same id. The same four lines live in Combobox, Listbox and Popover.
const nextAutoId = useCallback(() => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
}, []);
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. seedQuery(text) — imperative-only: write the input text
// (and therefore filteredOptions()'s filter) without touching the `value`
// model or selection state (a command-palette #2 levels/restore-on-pop
// prerequisite — repopulating the input on back-navigation is NOT a
// selection). pinOpen(v) — imperative-only: pin (or unpin) the popup open so
// onBlur() does not collapse it while a host sub-surface holds focus, AND
// (Phase 86-07 regression fix) so the composed Popover's OWN independent
// Escape/click-outside dismissal is vetoed too via `:disable-dismiss`
// (command-palette-sub-actions prerequisite). pinOpen(false) ONLY unpins — it
// does NOT itself close the popup or move focus; that is the host's job.
// Render-neutral when never called. All four are post-mount → $refs safe.
function focus() {
return inputEl.current?.focus();
}
function clear() {
// Fresh empty array under `multiple` (never in-place mutation), null in
// single mode — mirrors selectOption()'s `{ value, option, selected }`
// shape; nothing is selected after a clear, so `selected` is `false`.
const empty = props.multiple ? [] : null;
setValue(empty);
clearQuery(null);
setActiveIndex(-1);
props.onChange && props.onChange({
value: empty,
option: null,
selected: false
});
}
function seedQuery(text: any) {
setInputText(String(text == null ? '' : text));
}
function pinOpen(v: any) {
setPinned(!!v);
}
// query() — the current input text (what the last `search` reported).
function query() {
return inputText;
}
const _buildVirtualizerRef = useRef(buildVirtualizer);
_buildVirtualizerRef.current = buildVirtualizer;
const _syncQueryToValueRef = useRef(syncQueryToValue);
_syncQueryToValueRef.current = syncQueryToValue;
const _syncRowsRef = useRef(syncRows);
_syncRowsRef.current = syncRows;
useEffect(() => {
if (!_idBaseRef.current) setAutoId('rozie-combobox-' + nextAutoId());
_syncQueryToValueRef.current();
_syncRowsRef.current();
didMount.current = true;
// Routes through the SAME buildVirtualizer() the virtual $watch calls below
// (VIRT-BUILD) — one construction site, so the mount path cannot drift from the flip
// path.
if (_virtualRef.current) _buildVirtualizerRef.current();
}, []); // 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; }
if (expandedGroups && Object.keys(expandedGroups).length) setExpandedGroups({});
syncRows();
if (props.virtual && virtualizer.current) {
virtualizer.current.setOptions(virtualizerOptions());
virtualizer.current._willUpdate();
setWindowVer(prev => prev + 1);
scheduleRemeasure();
}
}, [inputText, props.options]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
if (expandedGroups && Object.keys(expandedGroups).length) setExpandedGroups({});
if (props.virtual) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => buildVirtualizer());else setTimeout(() => buildVirtualizer(), 0);
} else {
teardownVirtualizer();
}
}, [props.virtual]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ focus, clear, seedQuery, pinOpen, activeOption, query });
_rozieExposeRef.current = { focus, clear, seedQuery, pinOpen, activeOption, query };
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), seedQuery: (...args: Parameters<typeof seedQuery>): ReturnType<typeof seedQuery> => _rozieExposeRef.current.seedQuery(...args), pinOpen: (...args: Parameters<typeof pinOpen>): ReturnType<typeof pinOpen> => _rozieExposeRef.current.pinOpen(...args), activeOption: (...args: Parameters<typeof activeOption>): ReturnType<typeof activeOption> => _rozieExposeRef.current.activeOption(...args), query: (...args: Parameters<typeof query>): ReturnType<typeof query> => _rozieExposeRef.current.query(...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, "rozie-combobox--multiple": props.multiple, "rozie-combobox--block": props.block, "rozie-combobox--chips-inline": chipsInline() }), (attrs.className as string | undefined))} data-rozie-s-9546115a="">
<Popover trigger="manual" open={isOpen} onOpenChange={setIsOpen} bare={true} matchWidth={true} keepMounted={props.virtual} disablePositioning={props.inline} disableDismiss={props.inline || pinned} placement={props.placement} offset={props.offset} disableFlip={props.disableFlip} disableShift={props.disableShift} idBase={idRoot()} data-rozie-s-9546115a="" renderAnchor={() => (<>
<div className={"rozie-combobox-control"} data-rozie-s-9546115a="">
{!!(props.multiple) && <ul className={"rozie-combobox-chips"} data-rozie-s-9546115a="">
{chipRows().map((row, idx) => <li key={'chip-' + row.value} className={"rozie-combobox-chip"} data-rozie-s-9546115a="">
{(props.renderChip ?? props.slots?.['chip']) ? ((props.renderChip ?? props.slots?.['chip']) as Function)({ option: row.option, remove: () => onChipRemoveActivate(row.value), index: idx }) : <><span className={"rozie-combobox-chip__label"} data-rozie-s-9546115a="">{rozieDisplay(row.label)}</span><button type="button" className={"rozie-combobox-chip__remove"} disabled={!!props.disabled} aria-label={rozieAttr(chipRemoveLabel(row))} onMouseDown={($event) => { $event.preventDefault(); onChipRemovePointerDown(); }} onClick={($event) => { $event.stopPropagation(); onChipRemoveActivate(row.value); }} data-rozie-s-9546115a="">×</button></>}
</li>)}
</ul>}<input ref={inputEl} className={"rozie-combobox-input"} type="text" role="combobox" aria-autocomplete="list" aria-expanded={!!popupVisible()} aria-controls={rozieAttr(listId())} aria-activedescendant={rozieAttr(activeId())} aria-label={rozieAttr(props.ariaLabel)} value={inputText} placeholder={props.placeholder} disabled={!!props.disabled} autoComplete="off" onInput={($event) => { onInput($event); }} onFocus={($event) => { onFocus($event); }} onBlur={($event) => { onBlur($event); }} onKeyDown={($event) => { onKeydown($event); }} onPaste={($event) => { onPaste($event); }} onChange={($event) => { $event.stopPropagation(); onNativeInputChange(); }} data-rozie-s-9546115a="" />
</div>
</>)} children={<>
{!!(popupVisible() && !props.virtual && !isGrouped()) && <ul className={"rozie-combobox-list"} id={rozieAttr(listId())} role="listbox" aria-multiselectable={(props.multiple ? 'true' : undefined) ?? undefined} 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": isRowSelected(opt), "rozie-combobox-option--disabled": opt.disabled })} id={rozieAttr(optId(opt._i))} role="option" aria-selected={!!isRowSelected(opt)} 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: isRowSelected(opt), disabled: opt.disabled }) : (rozieDisplay(opt.label))}
</li>)}
{!!(filteredOptions().length === 0 && !isCreatableQuery()) && <li className={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ query: inputText }) : "No results"}
</li>}{!!(isCreatableQuery()) && <li className={clsx("rozie-combobox-option", "rozie-combobox-create", { "rozie-combobox-option--active": filteredOptions().length === activeIndex })} id={rozieAttr(optId(filteredOptions().length))} role="option" onMouseDown={($event) => { $event.preventDefault(); selectOption(createRowAt(filteredOptions().length)); }} onMouseEnter={($event) => { setActiveIndex(filteredOptions().length); }} data-rozie-s-9546115a="">
{(props.renderCreate ?? props.slots?.['create']) ? ((props.renderCreate ?? props.slots?.['create']) as Function)({ query: inputText }) : <>Create "{inputText}"</>}
</li>}</ul>}{!!(popupVisible() && !props.virtual && isGrouped() && !isCapped()) && <ul className={"rozie-combobox-list"} id={rozieAttr(listId())} role="listbox" aria-multiselectable={(props.multiple ? 'true' : undefined) ?? undefined} data-rozie-s-9546115a="">
{groupBlocks().map((blk) => <li key={'grp-' + (blk.group ? blk.group.id : '_ungrouped')} className={"rozie-combobox-group"} role="group" aria-label={rozieAttr(blk.group ? blk.group.label : undefined)} data-rozie-s-9546115a="">
{!!(blk.group) && <div className={"rozie-combobox-group-heading"} role="presentation" data-rozie-s-9546115a="">
{(props.renderGroupHeading ?? props.slots?.['groupHeading']) ? ((props.renderGroupHeading ?? props.slots?.['groupHeading']) as Function)({ group: blk.group }) : (rozieDisplay(blk.group.label))}
</div>}{blk.items.map((opt) => <div key={opt.value} className={clsx("rozie-combobox-option", { "rozie-combobox-option--active": opt._i === activeIndex, "rozie-combobox-option--selected": isRowSelected(opt), "rozie-combobox-option--disabled": opt.disabled })} id={rozieAttr(optId(opt._i))} role="option" aria-selected={!!isRowSelected(opt)} 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: isRowSelected(opt), disabled: opt.disabled }) : (rozieDisplay(opt.label))}
</div>)}
</li>)}
{!!(groupBlocks().length === 0 && !isCreatableQuery()) && <li className={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ query: inputText }) : "No results"}
</li>}{!!(isCreatableQuery()) && <li className={clsx("rozie-combobox-option", "rozie-combobox-create", { "rozie-combobox-option--active": filteredOptions().length === activeIndex })} id={rozieAttr(optId(filteredOptions().length))} role="option" onMouseDown={($event) => { $event.preventDefault(); selectOption(createRowAt(filteredOptions().length)); }} onMouseEnter={($event) => { setActiveIndex(filteredOptions().length); }} data-rozie-s-9546115a="">
{(props.renderCreate ?? props.slots?.['create']) ? ((props.renderCreate ?? props.slots?.['create']) as Function)({ query: inputText }) : <>Create "{inputText}"</>}
</li>}</ul>}{!!(popupVisible() && !props.virtual && isCapped()) && <ul className={"rozie-combobox-list"} id={rozieAttr(listId())} role="listbox" aria-multiselectable={(props.multiple ? 'true' : undefined) ?? undefined} data-rozie-s-9546115a="">
{cappedBlocks().map((blk) => <li key={'grp-' + (blk.group ? blk.group.id : '_ungrouped')} className={"rozie-combobox-group"} role="group" aria-label={rozieAttr(blk.group ? blk.group.label : undefined)} data-rozie-s-9546115a="">
{!!(blk.group) && <div className={"rozie-combobox-group-heading"} role="presentation" data-rozie-s-9546115a="">
{(props.renderGroupHeading ?? props.slots?.['groupHeading']) ? ((props.renderGroupHeading ?? props.slots?.['groupHeading']) as Function)({ group: blk.group }) : (rozieDisplay(blk.group.label))}
</div>}{blk.items.map((opt) => <div key={opt.value} className={clsx("rozie-combobox-option", { "rozie-combobox-option--active": opt._i === activeIndex, "rozie-combobox-option--selected": isRowSelected(opt), "rozie-combobox-option--disabled": opt.disabled })} id={rozieAttr(optId(opt._i))} role="option" aria-selected={!!isRowSelected(opt)} 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: isRowSelected(opt), disabled: opt.disabled }) : (rozieDisplay(opt.label))}
</div>)}
{!!(blk.more) && <div className={clsx("rozie-combobox-option", "rozie-combobox-more", { "rozie-combobox-option--active": blk.more._i === activeIndex })} id={rozieAttr(optId(blk.more._i))} role="option" onMouseDown={($event) => { $event.preventDefault(); selectOption(blk.more); }} onMouseEnter={($event) => { setActiveIndex(blk.more._i); }} data-rozie-s-9546115a="">
{(props.renderGroupMore ?? props.slots?.['groupMore']) ? ((props.renderGroupMore ?? props.slots?.['groupMore']) as Function)({ group: blk.group, hidden: blk.more.hidden, expand: blk.more.expand }) : <>+{rozieDisplay(blk.more.hidden)} more</>}
</div>}</li>)}
{!!(cappedBlocks().length === 0 && !isCreatableQuery()) && <li className={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ query: inputText }) : "No results"}
</li>}{!!(isCreatableQuery()) && <li className={clsx("rozie-combobox-option", "rozie-combobox-create", { "rozie-combobox-option--active": cappedRowCount() === activeIndex })} id={rozieAttr(optId(cappedRowCount()))} role="option" onMouseDown={($event) => { $event.preventDefault(); selectOption(createRowAt(cappedRowCount())); }} onMouseEnter={($event) => { setActiveIndex(cappedRowCount()); }} data-rozie-s-9546115a="">
{(props.renderCreate ?? props.slots?.['create']) ? ((props.renderCreate ?? props.slots?.['create']) as Function)({ query: inputText }) : <>Create "{inputText}"</>}
</li>}</ul>}{!!(props.virtual) && <ul className={"rozie-combobox-list rozie-combobox-list--virtual"} id={rozieAttr(listId())} role="listbox" aria-multiselectable={(props.multiple ? 'true' : undefined) ?? undefined} style={parseInlineStyle((popupVisible() ? '' : '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="" />
{windowedView().map((wr) => <li key={wr.row.id} className={clsx("rozie-combobox-option", { "rozie-combobox-option--active": wr.vi.index === activeIndex, "rozie-combobox-option--selected": isRowSelected(wr.row), "rozie-combobox-option--disabled": wr.row.disabled })} id={rozieAttr(optId(wr.vi.index))} data-index={rozieAttr(wr.vi.index)} role="option" aria-selected={!!isRowSelected(wr.row)} 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: isRowSelected(wr.row), 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 && !isCreatableQuery()) && <li className={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ query: inputText }) : "No results"}
</li>}{!!(isCreatableQuery()) && <li className={clsx("rozie-combobox-option", "rozie-combobox-create", { "rozie-combobox-option--active": windowSource().length === activeIndex })} id={rozieAttr(optId(windowSource().length))} role="option" onMouseDown={($event) => { $event.preventDefault(); selectOption(createRowAt(windowSource().length)); }} onMouseEnter={($event) => { setActiveIndex(windowSource().length); }} data-rozie-s-9546115a="">
{(props.renderCreate ?? props.slots?.['create']) ? ((props.renderCreate ?? props.slots?.['create']) as Function)({ query: inputText }) : <>Create "{inputText}"</>}
</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, 'rozie-combobox--multiple': props.multiple, 'rozie-combobox--block': props.block, 'rozie-combobox--chips-inline': chipsInline() }]" ref="__rozieRootRef" v-bind="$attrs">
<Popover trigger="manual" v-model:open="isOpen" :bare="true" :match-width="true" :keep-mounted="props.virtual" :disable-positioning="props.inline" :disable-dismiss="props.inline || pinned" :placement="props.placement" :offset="props.offset" :disable-flip="props.disableFlip" :disable-shift="props.disableShift" :id-base="idRoot()"><template #anchor>
<div class="rozie-combobox-control">
<ul v-if="props.multiple" class="rozie-combobox-chips">
<li v-for="(row, idx) in chipRows()" :key="'chip-' + row.value" class="rozie-combobox-chip">
<slot name="chip" :option="row.option" :remove="() => onChipRemoveActivate(row.value)" :index="idx">
<span class="rozie-combobox-chip__label">{{ row.label }}</span>
<button type="button" class="rozie-combobox-chip__remove" :disabled="!!props.disabled" :aria-label="chipRemoveLabel(row)" @mousedown.prevent="onChipRemovePointerDown()" @click.stop="onChipRemoveActivate(row.value)">×</button>
</slot>
</li>
</ul><input ref="inputElRef" class="rozie-combobox-input" type="text" role="combobox" aria-autocomplete="list" :aria-expanded="!!popupVisible()" :aria-controls="listId()" :aria-activedescendant="(activeId()) ?? undefined" :aria-label="props.ariaLabel" :value="inputText" :placeholder="props.placeholder" :disabled="!!props.disabled" autocomplete="off" @input="onInput($event)" @focus="onFocus($event)" @blur="onBlur($event)" @keydown="onKeydown($event)" @paste="onPaste($event)" @change.stop="onNativeInputChange()" />
</div>
</template>
<ul v-if="popupVisible() && !props.virtual && !isGrouped()" class="rozie-combobox-list" :id="listId()" role="listbox" :aria-multiselectable="(props.multiple ? 'true' : undefined) ?? undefined">
<li v-for="opt in filteredOptions()" :key="opt.value" :class="['rozie-combobox-option', { 'rozie-combobox-option--active': opt._i === activeIndex, 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }]" :id="optId(opt._i)" role="option" :aria-selected="!!isRowSelected(opt)" :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="isRowSelected(opt)" :disabled="opt.disabled">{{ opt.label }}</slot>
</li>
<li v-if="filteredOptions().length === 0 && !isCreatableQuery()" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="inputText">No results</slot>
</li><li v-if="isCreatableQuery()" :class="['rozie-combobox-option rozie-combobox-create', { 'rozie-combobox-option--active': filteredOptions().length === activeIndex }]" :id="optId(filteredOptions().length)" role="option" @mousedown.prevent="selectOption(createRowAt(filteredOptions().length))" @mouseenter="activeIndex = filteredOptions().length">
<slot name="create" :query="inputText">Create "{{ inputText }}"</slot>
</li></ul><ul v-if="popupVisible() && !props.virtual && isGrouped() && !isCapped()" class="rozie-combobox-list" :id="listId()" role="listbox" :aria-multiselectable="(props.multiple ? 'true' : undefined) ?? undefined">
<li v-for="blk in groupBlocks()" :key="'grp-' + (blk.group ? blk.group.id : '_ungrouped')" class="rozie-combobox-group" role="group" :aria-label="blk.group ? blk.group.label : undefined">
<div v-if="blk.group" class="rozie-combobox-group-heading" role="presentation">
<slot name="groupHeading" :group="blk.group">{{ blk.group.label }}</slot>
</div><div v-for="opt in blk.items" :key="opt.value" :class="['rozie-combobox-option', { 'rozie-combobox-option--active': opt._i === activeIndex, 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }]" :id="optId(opt._i)" role="option" :aria-selected="!!isRowSelected(opt)" :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="isRowSelected(opt)" :disabled="opt.disabled">{{ opt.label }}</slot>
</div>
</li>
<li v-if="groupBlocks().length === 0 && !isCreatableQuery()" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="inputText">No results</slot>
</li><li v-if="isCreatableQuery()" :class="['rozie-combobox-option rozie-combobox-create', { 'rozie-combobox-option--active': filteredOptions().length === activeIndex }]" :id="optId(filteredOptions().length)" role="option" @mousedown.prevent="selectOption(createRowAt(filteredOptions().length))" @mouseenter="activeIndex = filteredOptions().length">
<slot name="create" :query="inputText">Create "{{ inputText }}"</slot>
</li></ul><ul v-if="popupVisible() && !props.virtual && isCapped()" class="rozie-combobox-list" :id="listId()" role="listbox" :aria-multiselectable="(props.multiple ? 'true' : undefined) ?? undefined">
<li v-for="blk in cappedBlocks()" :key="'grp-' + (blk.group ? blk.group.id : '_ungrouped')" class="rozie-combobox-group" role="group" :aria-label="blk.group ? blk.group.label : undefined">
<div v-if="blk.group" class="rozie-combobox-group-heading" role="presentation">
<slot name="groupHeading" :group="blk.group">{{ blk.group.label }}</slot>
</div><div v-for="opt in blk.items" :key="opt.value" :class="['rozie-combobox-option', { 'rozie-combobox-option--active': opt._i === activeIndex, 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }]" :id="optId(opt._i)" role="option" :aria-selected="!!isRowSelected(opt)" :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="isRowSelected(opt)" :disabled="opt.disabled">{{ opt.label }}</slot>
</div>
<div v-if="blk.more" :class="['rozie-combobox-option rozie-combobox-more', { 'rozie-combobox-option--active': blk.more._i === activeIndex }]" :id="optId(blk.more._i)" role="option" @mousedown.prevent="selectOption(blk.more)" @mouseenter="activeIndex = blk.more._i">
<slot name="groupMore" :group="blk.group" :hidden="blk.more.hidden" :expand="blk.more.expand">+{{ blk.more.hidden }} more</slot>
</div></li>
<li v-if="cappedBlocks().length === 0 && !isCreatableQuery()" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="inputText">No results</slot>
</li><li v-if="isCreatableQuery()" :class="['rozie-combobox-option rozie-combobox-create', { 'rozie-combobox-option--active': cappedRowCount() === activeIndex }]" :id="optId(cappedRowCount())" role="option" @mousedown.prevent="selectOption(createRowAt(cappedRowCount()))" @mouseenter="activeIndex = cappedRowCount()">
<slot name="create" :query="inputText">Create "{{ inputText }}"</slot>
</li></ul><ul v-if="props.virtual" class="rozie-combobox-list rozie-combobox-list--virtual" :id="listId()" role="listbox" :aria-multiselectable="(props.multiple ? 'true' : undefined) ?? undefined" :style="(popupVisible() ? '' : '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 windowedView()" :key="wr.row.id" :class="['rozie-combobox-option', { 'rozie-combobox-option--active': wr.vi.index === activeIndex, 'rozie-combobox-option--selected': isRowSelected(wr.row), 'rozie-combobox-option--disabled': wr.row.disabled }]" :id="optId(wr.vi.index)" :data-index="wr.vi.index" role="option" :aria-selected="!!isRowSelected(wr.row)" :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="isRowSelected(wr.row)" :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 && !isCreatableQuery()" class="rozie-combobox-empty" role="presentation">
<slot name="empty" :query="inputText">No results</slot>
</li><li v-if="isCreatableQuery()" :class="['rozie-combobox-option rozie-combobox-create', { 'rozie-combobox-option--active': windowSource().length === activeIndex }]" :id="optId(windowSource().length)" role="option" @mousedown.prevent="selectOption(createRowAt(windowSource().length))" @mouseenter="activeIndex = windowSource().length">
<slot name="create" :query="inputText">Create "{{ inputText }}"</slot>
</li></ul></Popover>
</div>
</template>
<script lang="ts">
// The typed public surface (typed-surface P1; always TypeScript). `value` /
// `option` stay `any`: options are consumer-shaped objects (or primitives) the
// component never inspects beyond the label/value/disabled resolvers.
/** `search` payload — the current input text. */
export interface ComboboxSearchPayload {
query: string;
}
/** `change` payload — `option` is the raw source option (`null` for a clear or a free-text commit); `text` is set ONLY on free-text commits. */
export interface ComboboxChangePayload {
value: any;
option: any;
selected: boolean;
text?: string;
}
/** `create` payload — the (untrimmed) query the user asked to create. */
export interface ComboboxCreatePayload {
query: string;
}
/** An entry of the `groups` prop. */
export interface ComboboxGroup {
id: string;
label: string;
}
/** `chip` slot params — `remove()` removes the chip and refocuses the input. */
export interface ComboboxChipSlotCtx {
option: any;
remove: () => void;
index: number;
}
/** `option` slot params. */
export interface ComboboxOptionSlotCtx {
option: any;
index: number;
active: boolean;
selected: boolean;
disabled: boolean;
}
/** `empty` / `create` slot params. */
export interface ComboboxQuerySlotCtx {
query: string;
}
/** `groupHeading` slot params. */
export interface ComboboxGroupHeadingSlotCtx {
group: ComboboxGroup;
}
/** `groupMore` slot params. */
export interface ComboboxGroupMoreSlotCtx {
group: ComboboxGroup | null;
hidden: number;
expand: () => void;
}
export interface ComboboxHandle {
focus: () => void;
clear: () => void;
seedQuery: (text: string) => void;
pinOpen: (v: boolean) => void;
activeOption: () => any;
query: () => string;
}
</script>
<script setup lang="ts">
import Popover from '@rozie-ui/popover-vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
// 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';
// ---- native option grouping (combobox-native-groups: src/internal/groupOptions.ts) ----
// The PURE stable-partition helper is a RUNTIME import (unlike listCore/windowing
// above, it is NOT a compile-time `.rzts` partial that dissolves at compile) —
// codegen's `copyInternal` vendors it verbatim into each leaf at
// `./internal/groupOptions`, mirroring command-palette's `scoreCommands.ts`.
import { groupOptions } from './internal/groupOptions';
const props = withDefaults(
defineProps<{
/**
* The option list — `[{ value, label, disabled?, group? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, an optional `disabled` flag makes an option non-selectable, and an optional `group` string buckets the option under a matching entry of the `groups` prop (or a first-appearance fallback section) when grouping is active.
*/
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, option and popup elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`, the listbox id is `idBase + "-list"`. Leave it empty (the default) and each instance generates a unique id base after mount (`rozie-combobox-<n>`); set it when you need stable, predictable ids. 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. Unset (default) resolves through `effectiveCloseOnSelect()`: `true` in single-select (today's default behavior) and `false` in `multiple` mode, where closing after every chip pick would make multi-select unusable. Pass an explicit `true` or `false` to override in either mode.
*/
closeOnSelect?: boolean | null;
/**
* `value` widens to hold an **array** of selected values and remains the sole `model: true` prop, so the Angular `ControlValueAccessor` is preserved (a second model would forfeit it — `ROZ125`). Re-selecting an already-selected option toggles it off. Default `false` is byte-identical to single-select.
*/
multiple?: boolean;
/**
* When the user commits text matching no option (case-insensitive, trimmed, exact label equality — no Unicode normalization applied), combobox emits `create` with the query and writes NOTHING to `value` — the consumer adds the option to `options` and updates the model itself. Composes with `multiple`. Turning this on replaces the `#empty` fill with the `#create` row whenever the query is creatable (non-empty, no exact match); `#empty` still renders for an empty or whitespace-only query. Default `false` is byte-identical to today.
*/
creatable?: 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;
/**
* Ordered section list `[{ id, label }]` setting group order + heading text. Options are partitioned by their optional `group?` string; groups present on options but absent here fall back to first-appearance order after the listed ones. Empty/absent ⇒ flat, ungrouped rendering (default).
*/
groups?: any[];
/**
* Cap each native section group to its first `groupCap` results, adding a keyboard-reachable '+N more' row that expands that group IN PLACE when activated. `0`/absent = uncapped (default). Only applies to the non-virtual grouped render (`groups` non-empty); ignored when `virtual` is on.
*/
groupCap?: number;
/**
* Floating UI placement of the popup relative to the control, forwarded to the composed `@rozie-ui/popover` leaf — one of `top`/`right`/`bottom`/`left`, each optionally suffixed `-start`/`-end`. Default `"bottom-start"` matches the pre-Phase-86 static popup alignment (flush with the control's left edge). Ignored when `inline` is set.
*/
placement?: string;
/**
* Gap in pixels between the control and the popup, forwarded to the composed `@rozie-ui/popover` leaf. Default `4` preserves the pre-Phase-86 resting gap (`--rozie-combobox-list-gap`). Ignored when `inline` is set.
*/
offset?: number;
/**
* Disable the popup's Floating UI `flip` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup flips above the control when it would overflow the viewport below; set this to keep it pinned to `placement` regardless. Ignored when `inline` is set.
*/
disableFlip?: boolean;
/**
* Disable the popup's Floating UI `shift` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup shifts to stay within the viewport; set this to keep it strictly aligned to the control. Ignored when `inline` is set.
*/
disableShift?: boolean;
/**
* Fill the container: the root becomes `display: block; width: 100%`, the control (chips + input) stretches to that width, and the width-matched popup follows. Adds the `rozie-combobox--block` modifier class on the root. Default `false` keeps the fixed `--rozie-combobox-width` sizing.
*/
block?: boolean;
/**
* Chip rail layout under `multiple`: `'stacked'` (default) renders the chips above the input; `'inline'` puts the chips and the input on ONE wrapping row (the Tags layout), with the input taking the remaining width (`flex: 1`, never narrower than `--rozie-combobox-inline-input-min-width`). Only meaningful with `multiple`.
*/
chipLayout?: string;
/**
* Do not open the list when the input gains focus. Typing and ArrowDown / ArrowUp still open it. Default `false` opens on focus.
*/
disableOpenOnFocus?: boolean;
/**
* Show nothing instead of the empty state: when there are no option rows and no create row, the popup is not shown, the input reports `aria-expanded="false"`, and Escape is left to the host (not `preventDefault`ed). This is the supported way to render no popup at all; filling the `empty` slot with nothing still renders the fallback on most targets.
*/
hideEmpty?: boolean;
/**
* Keys that commit the **typed text** as a value (matched against the key event's `key`), under `multiple` only — a delimiter never picks the highlighted option. Character entries (e.g. `[',', ';']`) also split pasted text: a paste containing a delimiter is split on them, every non-empty trimmed part that `validate` accepts is committed, and the rejected parts are inserted at the caret (replacing the selection) like an ordinary paste, so text typed before the paste is kept. Use `splitPaste` to replace this split. `'Enter'` and `'Tab'` are allowed; Enter then commits the typed text only when no option is highlighted. A non-empty list (or `validate`, `splitPaste` or `commitOnBlur`) turns on free-text commits, so Enter with no highlighted option commits the typed text too. Default `[]` (off).
* @example
* <Combobox multiple v-model:value="to" :options="contacts" :delimiters="delims" />
*/
delimiters?: any[];
/**
* Free-text gate and normaliser, `(text: string) => string | boolean | null | undefined`, under `multiple` only. Called with the trimmed typed (or pasted) text before every free-text commit. Return the **string to store** (e.g. the bare address out of `Sam Roe <sam@x.test>`), `true` to store the text as typed, or a falsy value (`false` / `null` / `''`) to reject it — rejected text stays in the input. The same shape as Tags' `validate`. Setting it also turns on free-text commits (Enter with no highlighted option commits the typed text). A free-text commit appends the stored string to `value` (skipped when already present), clears the input, and emits `change` with `option: null` and the stored string as `text`.
* @example
* <Combobox multiple v-model:value="to" :options="contacts" :validate="toAddress" />
*/
validate?: ((...args: any[]) => any) | null;
/**
* Replaces the built-in paste split, `(text: string) => string[] | null`, under `multiple` only. Called with the clipboard text on every paste. Return the parts to commit — each is trimmed and passed through `validate`; accepted parts are committed and the rejected ones are inserted at the caret — or `null` to leave the paste to the browser untouched. Use it for syntax the delimiter split cannot know about, e.g. a quoted display name containing a comma (`"Roe, Sam" <sam@x.test>`). Setting it also turns on free-text commits.
* @example
* <Combobox multiple v-model:value="to" :options="contacts" :validate="toAddress" :split-paste="splitAddresses" />
*/
splitPaste?: ((...args: any[]) => any) | null;
/**
* Commit the typed text when the input loses focus, under `multiple` only, through `validate` like every other free-text commit: accepted text is committed and the input cleared, rejected text stays. A blur into a pinned host sub-surface (`pinOpen(true)`) does not commit. Setting it also turns on free-text commits. Default `false`.
*/
commitOnBlur?: boolean;
/**
* Tab picks the highlighted option while the popup is visible and an option is highlighted, keeping focus in the input. When nothing is picked, Tab moves focus normally. Default `false` (Tab always moves focus).
*/
selectOnTab?: boolean;
}>(),
{ options: () => [], placeholder: '', disabled: false, disableFilter: false, ariaLabel: null, idBase: '', inline: false, closeOnSelect: null, multiple: false, creatable: false, optionLabel: null, optionValue: null, optionDisabled: null, virtual: false, estimateRowHeight: 36, maxHeight: '', groups: () => [], groupCap: 0, placement: 'bottom-start', offset: 4, disableFlip: false, disableShift: false, block: false, chipLayout: 'stacked', disableOpenOnFocus: false, hideEmpty: false, delimiters: () => [], validate: null, splitPaste: null, commitOnBlur: false, selectOnTab: false }
);
/**
* 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 v-model:value="country" :options="countries" />
*/
const value = defineModel<unknown | null>('value', { default: null });
const emit = defineEmits<{
search: [payload: ComboboxSearchPayload];
change: [payload: ComboboxChangePayload];
create: [payload: ComboboxCreatePayload];
}>();
defineSlots<{
chip(props: { option: any; remove: () => void; index: number }): any;
option(props: { option: any; index: number; active: boolean; selected: boolean; disabled: boolean }): any;
empty(props: { query: string }): any;
create(props: { query: string }): any;
groupHeading(props: { group: ComboboxGroup }): any;
option(props: { option: any; index: number; active: boolean; selected: boolean; disabled: boolean }): any;
empty(props: { query: string }): any;
create(props: { query: string }): any;
groupHeading(props: { group: ComboboxGroup }): any;
option(props: { option: any; index: number; active: boolean; selected: boolean; disabled: boolean }): any;
groupMore(props: { group: ComboboxGroup | null; hidden: number; expand: () => void }): any;
empty(props: { query: string }): any;
create(props: { query: string }): any;
option(props: { option: any; index: number; active: boolean; selected: boolean; disabled: boolean }): any;
empty(props: { query: string }): any;
create(props: { query: string }): any;
}>();
const inputText = ref('');
const isOpen = ref(false);
const activeIndex = ref(-1);
const rows = ref<any[]>([]);
const windowVer = ref(0);
const editVer = ref(0);
const expandedGroups = ref({});
const createdQuery = ref<any>(null);
const pinned = ref(false);
const autoId = ref('');
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`).
// - `idRoot()` — the host's id base (Listbox: the `id` prop, else the per-instance id it
// generates in $onMount); `optionId` below derives every option id from it.
// - `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.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;
};
// `idRoot()` is a HOST function (the host's id base: its id prop, else a generated
// ══ 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.
// - rowsWindowed(): boolean — is the ROW axis windowed. REQUIRED, no default — replaces every bare
// truthiness read of the host's windowing prop (D-05); `windowedRows()` /
// `padTop()` / `padBottom()` / `rowIsOutsideWindow()` below call it by
// convention exactly as they already call `pinnedEditIndex()`.
// - colsWindowed(): boolean — is the COLUMN axis windowed. REQUIRED, no default. `false` for every
// host until it defines the real column-axis mechanism (87-04+).
// - columnCount(): number — the leaf-column count the column virtualizer windows over. REQUIRED,
// no default.
// - columnSize(i: number): number — the authoritative width of absolute leaf column `i`, sourced
// from table-core's `getSize()` under D-06. REQUIRED, no default.
// - forcedColumns(): number[] — the D-10 OPTIONAL column-axis mirror of `pinnedEditIndex()`: the
// DataTable host unions pinned + active-cell + editing column indices into
// the column-window slice; listbox/combobox pass an empty array (host-
// provided, defaulting to `[]`).
// - colVirtualizer — the host's SECOND virtual-core instance, windowing the COLUMN axis
// (see the AXIS MECHANISM note below). Host-provided, defaulting to `null`.
// - autoMeasureOn(): boolean — the D-18 REQUIRED content-driven-estimate gate (Phase 87 87-07):
// data-table's real body reads `$props.autoMeasure === true`; listbox/
// combobox/command-palette return `false` so the accumulator branch
// estimateRowSize() gates on is dead code for them (D-20).
// - afterRowRemeasure — OPTIONAL host-owned mutable `let` (defaults to a no-op / undefined),
// assigned to refineRowEstimate() (below) by the host. The DataTable
// host's remeasureWindow() (virtualization.rzts) calls it AFTER its
// measureElement sweep so the fold + hysteresis re-feed run on every
// window commit. Routed through a mutable `let` rather than a direct
// call FROM virtualization.rzts INTO this file: a relative-partial CONST
// calling a bare-specifier-partial CONST is the exact forward-reference
// TDZ class remeasureColumnWindow()'s own DataTable.rozie comment
// documents for columnVirtualizerOptions() (inlineScriptPartials()
// groups the relative partial BEFORE the bare-specifier partial in the
// merged per-target output, regardless of source import order). A
// mutable `let` hoists to `useRef` on React and is excluded from a
// useCallback's dependency array, sidestepping the hazard entirely — the
// SAME mechanism `refreshRowModel` already relies on.
//
// AXIS MECHANISM (OQ1 / Assumption A1 — resolved from the installed source in 87-02;
// LANDED in 87-04: `columnVirtualizerOptions()` below IS the second, horizontal instance this
// note originally only documented). `horizontal` is a PER-INSTANCE field of `VirtualizerOptions`
// (`node_modules/@tanstack/virtual-core/dist/esm/index.d.ts:67`, installed version 3.17.1 per
// `package.json`), and every axis-sensitive internal read consults `instance.options.horizontal` —
// `measureElement`'s inlineSize/blockSize + offsetWidth/offsetHeight branch
// (`dist/esm/index.js:137,150`), `observeElementOffset`'s scrollLeft/scrollTop branch
// (`dist/esm/index.js:118-121`), `getMaxScrollOffset`'s scrollWidth/scrollHeight branch
// (`dist/esm/index.js:907-915`), and `scrollWithAdjustments`'s left/top branch
// (`dist/esm/index.js:152-161`). So ONE `Virtualizer` instance windows exactly ONE axis: the column
// axis needs its own SECOND, independent `Virtualizer` instance constructed with `horizontal: true`,
// sharing the SAME `getScrollElement()` (the `rdt-scroll` wrapper) the row instance already uses.
// Two options the row axis does not set that the column instance will need: `isRtl?: boolean`
// (data-table ships an RTL grid path) and `overscan?: number` (D-07 gives the column axis its own
// hardcoded constant, separate from the row axis's `overscan: 8` below).
//
// isRtl WIRING (gap-closure 87-09, LANDED — see `ensureColRtlWatch()`/`isColRtl()` below,
// immediately ahead of `columnVirtualizerOptions()`): data-table has no construction-time RTL
// signal (no `dir`/`rtl` prop), and `dir` can be set on `gridScrollEl` at ANY point relative to
// mount. `isRtl` is therefore computed LIVE via `getComputedStyle`, not baked in once.
// 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;
};
// COL_OVERSCAN (D-07): the column axis's own hardcoded overscan constant, separate from the
// row axis's `overscan: 8` below. Columns are far wider than rows are tall, so one number
// cannot serve both axes; no prop is exposed because no consumer has asked to tune the row
// overscan across the four phases it has shipped. Unused until 87-04 constructs the second,
// horizontal Virtualizer instance (see the AXIS MECHANISM note above).
// ══ Phase 87 87-07 (D-15/D-18) — content-driven auto-measure: the shared engine's FIRST
// mutable top-level state. Hoisted to `useRef` PER-INSTANCE by the React emitter's
// hoistModuleLet — the SAME mechanism already load-bearing for `table`, `virtualizer`,
// `remeasurePending`, and `gridScrollEl` in the DataTable host (Task 1's confirmed
// precedent), so two DataTable instances on one page never share an accumulator
// (T-87-07-04). measuredRowTotal/measuredRowCount together give the running MEAN of every
// row folded in so far; lastFedRowEstimate is the estimate value most recently pushed into
// virtual-core (the hysteresis comparison baseline). ══
let measuredRowTotal = 0;
let measuredRowCount = 0;
// ══ Gap-closure 87-10 — windowVerBumpPending / bumpWindowVer(): coalesce EVERY $data.windowVer
// write behind a SINGLE microtask-deferred increment, regardless of how many callers request
// one within the same synchronous JS task. ══
//
// ROOT CAUSE (framework-agnostic; the Solid-specific symptom this closes only EXPOSES it) —
// confirmed by instrumenting the installed @tanstack/virtual-core@3.17.1 source directly
// (dist/esm/index.js), not by reasoning abstractly: virtual-core's resizeItem() calls
// `this.notify(false)` — synchronously invoking `virtualizerOptions().onChange` below — EVERY
// TIME a measured row's real size differs from its cached one (`delta !== 0`), independent of
// framework (dist/esm/index.js:836-874). remeasureWindow()'s CR-01 sweep
// (packages/ui/data-table/src/virtualization.rzts) measures EVERY currently-rendered `<tr>` in
// ONE for-loop BEFORE calling afterRowRemeasure() (refineRowEstimate() below) — so a single
// synchronous JS task (e.g. the very first measurement pass, which transitions N never-before-
// measured rows from the flat seed to their real heights) can fire onChange, and therefore an
// UNCOALESCED `$data.windowVer = $data.windowVer + 1`, MANY times in a row — well BEFORE
// refineRowEstimate()'s own fold-then-re-feed (which runs only AFTER that loop finishes) has
// folded those same measurements into the running mean or re-fed the converged estimate into
// virtual-core via setOptions(). A live trace of this exact sequence (instrumented resizeItem/
// getMeasurements calls, DataTableColumnVirtualDemo, autoMeasure on) showed Vue batching 3
// resizeItem calls before its ONE downstream re-render reads getMeasurements() — already
// reflecting the fully-folded, re-fed state — versus Solid re-running its padTop()/padBottom()
// effects SYNCHRONOUSLY and IMMEDIATELY on EVERY individual windowVer write (11 interleaved
// resize-then-immediate-recompute pairs, each recompute happening mid-sweep, before
// refineRowEstimate() had run even once). React/Vue/Svelte/Angular/Lit all batch their own
// reactivity to at least a microtask boundary, so their downstream reads land AFTER the whole
// synchronous burst (measurement sweep + fold + re-feed) completes — accidentally correct, not
// correct by construction. Solid does not auto-batch a signal write made from outside a
// Solid-owned event/effect context, so it is the one target where the mid-burst TORN read is
// externally observable. Because `setOptions()` + `_willUpdate()` alone do NOT invalidate
// virtual-core's own `getMeasurements()` memo (keyed on itemSizeCacheVersion /
// getMeasurementOptions() — never on the estimateSize FUNCTION reference itself; confirmed from
// the same installed source, dist/esm/index.js:585-587,624), Solid's LAST such mid-sweep
// recompute is also the LAST time getMeasurements() is ever invoked for that sweep once no
// further row happens to differ from its cache — so the DOM stays frozen on that stale,
// pre-fold/pre-re-feed snapshot indefinitely, even though the accumulator itself has already
// converged correctly (T-87-07's own confirmed finding).
//
// FIX: coalesce every requester of a windowVer bump — virtual-core's own onChange AND
// refineRowEstimate()'s explicit re-feed bump — behind ONE microtask-deferred write, the SAME
// idiom scheduleRemeasure() already uses in virtualization.rzts. This makes the render happen
// EXACTLY ONCE, strictly AFTER the entire synchronous burst (including refineRowEstimate()'s
// fold + re-feed) on EVERY target, by construction rather than by incidental host-framework
// batching. Scoped to the ROW axis only: colVirtualizer never calls resizeItem() at all (D-06 —
// column widths come from table-core's getSize() oracle, never measured from the DOM), so
// columnVirtualizerOptions()'s onChange cannot hit this burst class and is left untouched.
let windowVerBumpPending = false;
const bumpWindowVer = (): void => {
if (windowVerBumpPending) return;
windowVerBumpPending = true;
const flush = () => {
windowVerBumpPending = false;
windowVer.value = windowVer.value + 1;
};
// Mirrors scheduleRemeasure()'s own defensive queueMicrotask-with-setTimeout-fallback
// (virtualization.rzts) for environments where queueMicrotask is unavailable.
if (typeof queueMicrotask !== 'undefined') queueMicrotask(flush);else setTimeout(flush, 0);
};
// ESTIMATE_REFEED_DELTA_PX (D-15): the hysteresis threshold gating a re-feed into
// virtual-core. Without it, a mean nudging by a fraction of a pixel on every fold would
// re-feed on every window commit — the T-87-07-01 DoS control, paired with virtual-core's
// own measureElement/resizeItem idempotence (see refineRowEstimate() below).
// estimateRowSize(i) (D-15/D-17): the estimateSize() resolver. MUST check !autoMeasureOn()
// FIRST so the off path touches zero accumulator state and returns $props.estimateRowHeight
// verbatim (D-17's byte-behavioral no-op). The zero-measurements case (first paint,
// regardless of autoMeasure) still returns the seed — the very first render has nothing
// measured yet either way (D-15).
const estimateRowSize = (i: number): number => {
if (!autoMeasureOn()) return props.estimateRowHeight;
if (measuredRowCount === 0) return props.estimateRowHeight;
return Math.round(measuredRowTotal / measuredRowCount);
};
// foldMeasuredRow(index, height): fold ONE measured row's height into the running-mean
// accumulator, UPDATING (not double-adding) an already-folded index (T-87-07-03).
// 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's windowVer write is routed through
// bumpWindowVer() (87-10) rather than a raw `$data.x = $data.x + 1` — resizeItem() can call
// this onChange MANY times in a single synchronous sweep (once per row whose real measured
// size differs from its cache, e.g. every never-before-measured row in the FIRST window),
// and coalescing those into one microtask-deferred write is what keeps every target's render
// landing strictly AFTER the whole sweep (see bumpWindowVer()'s own comment for the confirmed
// Solid-specific rendering gap this closes). The React emitter still lowers the underlying
// `$data.windowVer = $data.windowVer + 1` to functional setState — correct even deferred to a
// microtask, exactly as it was correct from a mount closure before.
const virtualizerOptions = (): any => ({
count: windowSource().length,
getScrollElement: () => gridScrollEl,
estimateSize: (i: any) => estimateRowSize(i),
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
bumpWindowVer();
// 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.
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).
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) {
// Rows OFF (Phase 87 D-04: this now includes the colsWindowed()-only path, since the
// wrapper template is entered whenever isWindowed(), not just rowsWindowed() — the row
// virtualizer is never constructed when only the column axis is windowed, D-04) → the FULL
// set, with a SYNTHETIC `vi.index` set to each row's array position (matching rowIndexOf's
// own `$data.rows.indexOf(row)` semantics exactly, since $data.rows IS windowSource()'s
// output here). Every windowed body binding reads wr.vi.index (data-row, aria-rowindex,
// colIndexOf, isEditing, the fill handle) — a bare `null` there is a hard crash the moment
// this branch is reached with the wrapper mounted, which colsWindowed()-only now does.
// Row-virtual ON but the virtualizer is not yet constructed (pre-$onMount first paint) →
// render NOTHING so the template never dereferences a not-yet-real `vi`; the rows appear on
// the first onChange after _didMount.
if (!rowsWindowed()) {
const rowList = rows.value || [];
return rowList.map((r: any, i: any) => ({
vi: {
index: i
},
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.
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 (!rowsWindowed() || !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 (!rowsWindowed() || !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?
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).
const rowIsOutsideWindow = (r: any) => {
if (!rowsWindowed() || !virtualizer) return false;
const items = virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
// ══ Phase 87 87-04 — the column-axis analogs of windowedRows()/padTop()/padBottom()/
// rowIsOutsideWindow() above. The column axis has no "row-shaped" identity to carry alongside
// a VirtualItem (a column is not a full-model object the way a row is), so windowedColIndices()
// returns bare ABSOLUTE leaf-column indices; the template resolves each index back to a header/
// cell through the host's own header-group / visibleCellsFor lookups (D-08/D-09). ══
// windowedColIndices(): the ordered array of ABSOLUTE leaf-column indices to render.
// 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;
// Scroll-end pin state (see recordScrollEnd()): whether the USER left the view at the end,
// the option count at that moment, and the last scrollTop already accounted for.
let scrollEndPinned: boolean = false;
let scrollEndPinnedCount: number = -1;
let scrollEndPinnedTop: number = -1;
// Non-reactive per-instance flag (Phase 86 R2, plan 86-03, Solid-only): true for
// the duration of an onFocus-triggered open transition (set before the isOpen
// write, cleared in the deferred microtask after). Lets onBlur distinguish a
// blur caused by Solid recreating the anchor's DOM mid-open (skip closing) from
// a genuine user-initiated blur (close normally). See onFocus/onBlur below.
let openingInProgress = false;
// Non-reactive per-instance flag (combobox-virtual-reactivity phase): set true once
// $onMount has run; read by windowedView() below so the blank-frame fallback (D-4) only
// fires on a genuine RUNTIME flip — a virtual:true-at-mount (never-flipped) consumer's
// first paint stays byte-stable (windowedRows()'s own pre-mount `[]` still applies before
// didMount flips true). Mirrors the same write-in-$onMount/read-elsewhere holder class.
let didMount = 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.
//
// Quick 260717-8zb dogfood: re-expressed on the `$memo(fn, keyFn)` primitive.
// `$memo` lowers (core, shared across all 6 targets) to a member-mutated
// fresh-object cache const + a wrapper function — EXACTLY this foCache shape,
// generalized. On React the emitted cache const is stabilized to
// `useMemo(() => ({…}), [])` by the EXISTING collectMutatedInstanceBinders/
// tryWrapMutatedInstanceUseMemo machinery (feedback_react_const_mutinstance_
// not_stabilized) — no per-target $memo code. On the 5 setup-once targets the
// top-level consts persist for the instance lifetime naturally.
//
// keyFn is the SUBSCRIBE-FIRST half (fine-grained Solid <For> / Svelte
// {#each}): it reads ALL FOUR reactive inputs UNCONDITIONALLY — $data.inputText
// even when disableFilter is true (mirrors windowing.rzts windowedRows
// void-touch discipline) and $props.groups even when $props.virtual (so a
// groups change while windowed still invalidates the cache once virtual
// toggles off) — evaluated BEFORE $memo's cache-hit check, so the r-for
// accessor subscribes to them on every eval. 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 / groups-ref) change.
//
// fn is the MISS path (unchanged from the hand-rolled foCache): run the
// filter, then (native option grouping, combobox-native-groups) a
// NON-VIRTUAL-ONLY stable re-partition into group-visual order, then map to
// wrapper rows.
const filteredOptionsCache = {
keys: null as any[] | null,
val: null as any
};
const filteredOptions = () => {
const __rozieMemoKey = (() => {
const opts = Array.isArray(props.options) ? props.options : [];
const df = !!props.disableFilter;
const q = String(inputText.value == null ? '' : inputText.value);
const groupsProp = props.groups;
return [opts, q, df, groupsProp];
})();
const __rozieMemoPrev = filteredOptionsCache.keys;
if (__rozieMemoPrev !== null && __rozieMemoPrev.length === __rozieMemoKey.length && __rozieMemoKey.every((v: any, i: any) => v === __rozieMemoPrev[i])) {
return filteredOptionsCache.val;
}
const __rozieMemoVal = (() => {
const opts = Array.isArray(props.options) ? props.options : [];
const df = !!props.disableFilter;
const q = String(inputText.value == null ? '' : inputText.value);
const groupsProp = props.groups;
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
// Gated to !$props.virtual (groups×virtual is deferred/unsupported per design) AND to
// $props.groups being a NON-EMPTY array — an explicit author opt-in. This is deliberately
// NOT just "!$props.virtual" (groupOptions() would otherwise also fire whenever any raw
// option happens to carry a `.group` field, even with `groups` absent — a real collision
// discovered against command-palette's CommandItem.group, which is a PRE-EXISTING,
// unrelated per-row-badge field, not an opt-in to combobox's native grouping. The design's
// "Empty/absent `groups` ⇒ today's flat behavior, byte-identical" contract is about the
// `groups` PROP only — never inferred from incidental option shape.
if (!props.virtual && Array.isArray(groupsProp) && groupsProp.length > 0) {
const partition = groupOptions(list, groupsProp, (o: any) => o && o.group != null ? String(o.group) : null);
list = partition.ordered;
}
// `_i` is assigned over the (now group-ordered) list, so the flat keyboard model
// (activeIndex/aria-activedescendant/nextEnabled) walks visual order unchanged.
// `group` carries the wrapper's normalized group id for groupBlocks() below.
return list.map((o: any, i: any) => ({
value: valueOf(o),
label: labelOf(o),
disabled: disabledOf(o),
_i: i,
id: valueOf(o),
option: o,
group: o && o.group != null ? String(o.group) : null
}));
})();
filteredOptionsCache.keys = __rozieMemoKey;
filteredOptionsCache.val = __rozieMemoVal;
return __rozieMemoVal;
};
// 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();
// windowedView() (combobox-virtual-reactivity, VIRT-FALLBACK): the combobox-side
// blank-frame fallback for the mid-flip frame. While `virtual` is on but the virtualizer
// has not yet (re)attached (didMount-gated, so the never-flipped virtual:true-at-mount
// first paint is untouched — windowedRows()'s own pre-mount `[]` still governs it),
// render the UN-WINDOWED full windowSource() slice mapped to the `{ vi: { index }, row }`
// shape the windowed template consumes (`wr.vi.index` resolves to the wrapper's own `_i`,
// since windowSource() IS the filtered/indexed list navRows()/activeIndex already walk).
// Once the virtualizer is built, delegates to windowedRows() UNCHANGED — byte-identical
// to today's steady windowed state. Entirely combobox-side: @rozie-ui/headless-core/
// windowing.rzts is untouched, preserving data-table's B13 A==B byte-identity + its
// empty-diff regen.
const windowedView = () => {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}) — touch windowVer at the
// TOP, mirroring windowedRows()'s own subscribe-first discipline (windowing.rzts), so
// the accessor re-runs when buildVirtualizer()/kickWindow() bump windowVer once the
// virtualizer attaches — the transition OUT of this fallback and into windowedRows().
void windowVer.value;
if (props.virtual && !virtualizer && didMount) {
return windowSource().map((row: any) => ({
vi: {
index: row._i
},
row
}));
}
return windowedRows();
};
// ---- native option grouping render helpers (combobox-native-groups) ---------------
// groupBlocks(): re-partition the ALREADY group-ordered filteredOptions() wrappers into
// CONTIGUOUS runs by wrapper.group (trivial + guarantees `_i` alignment, since `ordered`
// from groupOptions() is already group-contiguous). Attaches each run's `{ id, label }`
// from $props.groups (fallback label = the group id itself). Plain function — never
// $computed (mirrors filteredOptions()'s convention). Non-virtual only (isGrouped() below
// already gates the template branch that calls this).
const groupBlocks = () => {
const wrappers = filteredOptions();
const groupsProp = Array.isArray(props.groups) ? props.groups : [];
const labelFor = (gid: any) => {
const found = groupsProp.find((g: any) => g && g.id === gid);
return found ? found.label : gid;
};
const blocks = [];
let lastGid;
for (let i = 0; i < wrappers.length; i++) {
const w = wrappers[i];
if (i === 0 || w.group !== lastGid) {
blocks.push({
group: w.group == null ? null : {
id: w.group,
label: labelFor(w.group)
},
items: [w]
});
} else {
blocks[blocks.length - 1].items.push(w);
}
lastGid = w.group;
}
return blocks;
};
// isGrouped(): the grouped-vs-flat template branch selector. Grouping is active
// (non-virtual only) SOLELY when the author explicitly set a non-empty `groups` prop —
// deliberately NOT "OR any option carries a group" (a real collision discovered against
// command-palette's pre-existing CommandItem.group per-row-badge field; see the
// filteredOptions() comment above). Mirrors that same non-empty-`groups` gate exactly, so
// isGrouped() and the filteredOptions() partition never disagree about which branch is active.
const isGrouped = () => !props.virtual && Array.isArray(props.groups) && props.groups.length > 0;
// ---- per-group result cap + expand-in-place "+N more" (combobox-group-cap) --------
// capNum(): coerce $props.groupCap to a whole, positive cap; anything else (NaN,
// negative, absent) degrades to 0 (uncapped). Plain function — never $computed.
const capNum = () => {
const n = Number(props.groupCap);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
};
// isCapped(): the capped-render branch selector. isGrouped() already gates non-
// virtual + non-empty `groups`, so the cap is automatically gated OUT of the
// virtual and ungrouped paths.
const isCapped = () => isGrouped() && capNum() > 0;
// gkey(gid): normalize a group id (possibly null, for the leading ungrouped
// section) into an expandedGroups map key.
const gkey = (gid: any) => gid == null ? '__ungrouped__' : String(gid);
// isExpanded(gid): whether the group has been expanded via its "+N more" row.
const isExpanded = (gid: any) => !!(expandedGroups.value && expandedGroups.value[gkey(gid)]);
// expandGroup(gid): replace $data.expandedGroups IMMUTABLY (load-bearing for
// React re-render — feedback_react_const_mutinstance_not_stabilized / the
// graph-writeback immutability rule).
const expandGroup = (gid: any) => {
expandedGroups.value = Object.assign({}, expandedGroups.value, {
[gkey(gid)]: true
});
};
// cappedBlocks(): the visible-block model for the capped render — groupBlocks()
// re-sliced to `capNum()` per group (unless expanded or non-overflowing), with a
// trailing "+N more" row appended to any still-capped block. Re-indexes `_i` as a
// running counter over the WHOLE visible+more sequence so option ids/aria-
// activedescendant stay contiguous and never disagree with navRows() below.
const cappedBlocks = () => {
const blocks = groupBlocks();
const cap = capNum();
let running = 0;
const out = [];
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
const gid = blk.group ? blk.group.id : null;
const showAll = isExpanded(gid) || blk.items.length <= cap;
const visibleSrc = showAll ? blk.items : blk.items.slice(0, cap);
const items = [];
for (let vi = 0; vi < visibleSrc.length; vi++) {
items.push(Object.assign({}, visibleSrc[vi], {
_i: running
}));
running++;
}
let more: any = null;
if (!showAll) {
more = {
isMore: true,
group: gid,
hidden: blk.items.length - cap,
disabled: false,
_i: running,
expand: () => expandGroup(gid)
};
running++;
}
out.push({
group: blk.group,
items,
more
});
}
return out;
};
// ---- creatable mode (Phase 86 R3, D-17..D-20) ---------------------------
// normalizedQuery(): trimmed + lower-cased query — reuses the SAME case-fold
// filteredOptions() already applies above, but for an EXACT-EQUALITY
// comparison, never a substring search, and with NO Unicode normalization
// (R3 locked: a composition-form difference must NOT be treated as a match).
const normalizedQuery = () => String(inputText.value == null ? '' : inputText.value).trim().toLowerCase();
// queryMatchesOption(nq): whether the (already-normalized) query is an exact,
// case-insensitive, trimmed match of some option's label.
const queryMatchesOption = (nq: any) => {
const opts = Array.isArray(props.options) ? props.options : [];
return opts.some((o: any) => String(labelOf(o)).trim().toLowerCase() === nq);
};
// isCreatableQuery(): the create-row visibility gate (also gates the `#empty`
// -> `#create` swap, D-19). `creatable` must be set, the normalized query
// must be non-empty (an empty/whitespace-only query never offers create —
// `#empty` keeps its job there), and no option's normalized label may equal
// it exactly.
const isCreatableQuery = () => {
if (!props.creatable) return false;
const nq = normalizedQuery();
if (!nq) return false;
return !queryMatchesOption(nq);
};
// createRowAt(baseCount): the synthetic, non-option `role="option"` create
// row (D-17) — mirrors the `groupMore` "+N more" row shape exactly (a real
// id, arrow-reachable, commits through the SAME selectOption() dispatch
// without writing the model). Each render branch passes ITS OWN flattened
// pre-create-row row count (`baseCount`) as the running index, exactly as
// `cappedBlocks()` already re-indexes `_i` across options + the more row —
// so ids / aria-activedescendant / navRows() can never disagree.
const createRowAt = (baseCount: any) => ({
isCreate: true,
_i: baseCount,
disabled: false
});
// cappedRowCount(): the total navigable row count cappedBlocks() flattens to
// (visible items + more-rows, across every block) — the running index the
// capped branch's own create row (below) must continue from. Mirrors
// cappedBlocks()'s own `running` counter without re-deriving `_i` per item.
const cappedRowCount = () => {
const blocks = cappedBlocks();
let n = 0;
for (let bi = 0; bi < blocks.length; bi++) {
n += blocks[bi].items.length;
if (blocks[bi].more) n++;
}
return n;
};
// navRows(): the SINGLE keyboard/aria source of truth. Returns the EXACT
// filteredOptions() reference when not capped and not creatable (byte-
// identical-off — untouched virtual/ungrouped keyboard path); flattens
// cappedBlocks() into visible items + more-rows, in order, when capped.
// Appends the create row, AFTER the full flattened visible(+more) sequence,
// whenever isCreatableQuery() — R3's locked "renders last, after all options
// and group sections" is a positional fact here, not a per-branch special case.
const navRows = () => {
if (!isCapped()) {
const base = filteredOptions();
if (!isCreatableQuery()) return base;
return base.concat([createRowAt(base.length)]);
}
const out = [];
const blocks = cappedBlocks();
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
for (let ii = 0; ii < blk.items.length; ii++) out.push(blk.items[ii]);
if (blk.more) out.push(blk.more);
}
if (isCreatableQuery()) out.push(createRowAt(out.length));
return out;
};
// 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;
// D-05 windowing.rzts host-contract one-liner (Phase 87 87-02). rowsWindowed() preserves
// today's EXACT truthiness (byte-behavior-identical) — it is the REQUIRED symbol
// windowing.rzts calls in place of a bare `$props.virtual` read.
//
// GAP-CLOSURE 87-16 (WR-02): the column-axis host-contract symbols (`colVirtualizer`,
// `colsWindowed()`, `columnCount()`, `columnSize()`, `forcedColumns()`) that 87-02 added
// alongside this were REMOVED here — they were dead code shipped on a mistaken premise
// about the compiler's tree-shaking BFS. Combobox imports only `{ virtualItemKey,
// virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow }`
// from windowing.rzts; none of those functions' bodies reference the column-axis symbols
// (only `columnVirtualizerOptions()`/`windowedColIndices()`/`colPadLeft()`/`colPadRight()`/
// `colIsOutsideWindow()` do, and Combobox never imports any of those), so
// `inlineScriptPartials()`'s BFS never needed them to exist. See 87-REVIEW.md WR-02 /
// 87-16-SUMMARY.md for the verification trail.
const rowsWindowed = () => !!props.virtual;
// autoMeasureOn() (Phase 87 87-07, D-18/D-20): the content-driven-estimate host-contract
// gate. Combobox never lights this branch — a permanent `false` keeps windowing.rzts's
// estimateRowSize()/refineRowEstimate() accumulator dead code here. RETAINED (unlike the
// column-axis symbols above): `virtualizerOptions()` — which Combobox DOES import and call
// — wires `estimateSize: (i) => estimateRowSize(i)`, and `estimateRowSize()` calls
// `autoMeasureOn()` as its first line. This one IS reachable through the import graph.
const autoMeasureOn = (): boolean => false;
// Keep $data.rows === windowSource() so the windowing math indexes the live filtered set.
const syncRows = () => {
rows.value = windowSource();
};
// SCROLL-END PIN (the data-table D-19 twin, shared shape with Listbox): keep a user who
// scrolled to the END of a variable-height list at the end while the options in view measure
// taller than their estimate. The view is judged on the DOM, and only at a move the USER
// made — a move is virtual-core's own when it still holds an unreconciled scroll adjustment
// (scrollAdjustments !== 0): its above-viewport compensation writes an ABSOLUTE scrollTop
// computed from its last-observed (stale) offset, so it pulls the view back up from the end
// and must neither clear the pin nor be mistaken for the user leaving the end. That position
// is remembered so the scroll event that later reports it is not read as a user move either.
// (Judging on virtual-core's MODEL, as the data-table host does, fails here: its total grows
// with every option measured in the ResizeObserver batch while its offset stays at the stale
// value, so the pin was cleared mid-batch — every target ended 10-126px short, measured.)
const recordScrollEnd = () => {
if (!virtualizer || !gridScrollEl || virtualizer.scrollState) return;
const top: number = gridScrollEl.scrollTop;
if (top === scrollEndPinnedTop) return;
scrollEndPinnedTop = top;
if (virtualizer.scrollAdjustments !== 0) return;
// Only a list that actually overflows has an end to hold: while the window has not painted
// yet (or the list is closed), scrollHeight <= clientHeight reads as "at the end" and a pin
// recorded then would jump the freshly opened list to the bottom.
const sh = gridScrollEl.scrollHeight;
const ch = gridScrollEl.clientHeight;
scrollEndPinned = ch > 0 && sh - ch > 1 && sh - top - ch <= 1;
scrollEndPinnedCount = windowSource().length;
};
// Re-apply the pin after the framework has committed the window (called from the rAF pass):
// the real maximum is known only then. Not while a programmatic scroll (scrollToIndex) is in
// flight, and not when the option count changed since the user reached the end (a new query
// or appended options must not be auto-followed).
const keepScrollEnd = () => {
if (!scrollEndPinned || !virtualizer || !gridScrollEl || virtualizer.scrollState) return;
if (windowSource().length !== scrollEndPinnedCount) return;
const maxTop: number = gridScrollEl.scrollHeight - gridScrollEl.clientHeight;
if (maxTop - gridScrollEl.scrollTop > 1) {
gridScrollEl.scrollTop = maxTop;
scrollEndPinnedTop = gridScrollEl.scrollTop;
}
};
// 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 = () => {
recordScrollEnd();
if (remeasurePending) return;
remeasurePending = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
// N-05 (quick 260923-rrr): key the rAF pass on the OUTCOME. React and Angular commit the
// recycled window AFTER the first rAF, so one pass measured the OLD options and the new ones
// waited for virtual-core's 150ms scrolling-ended tick — with variable-height options the late
// above-viewport adjustment then moved the whole list (measured). Re-run next frame until the
// committed options cover the virtualizer's window, bounded (the data-table host twin).
let rafAttempts = 0;
const rafPass = () => {
const covered = remeasureWindow();
rafAttempts = rafAttempts + 1;
if (!covered && rafAttempts < 10 && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(rafPass);
return;
}
keepScrollEnd();
remeasurePending = false;
};
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 true;
if (virtualizer.scrollState) return true;
const els = gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]');
const rendered = new Set();
for (const el of els as any) {
virtualizer.measureElement(el);
rendered.add(el.getAttribute('data-index'));
}
// N-05: false while the framework has not yet committed the recycled window.
const items = virtualizer.getVirtualItems();
for (let i = 0; i < items.length; i++) {
if (!rendered.has(String(items[i].index))) return false;
}
return true;
};
// Keep the active option visible inside the popup. When windowing, route through the
// virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window scrolls
// into view (the windowed-arrow-nav seam). When NOT windowing, resolve the active
// option element directly (a within-own-shadow query, Lit-safe) and scrollIntoView it
// with 'nearest' block alignment — a plain long list taller than the popup's
// max-height must also keep the active option visible during arrow navigation.
const scrollActiveIntoView = () => {
if (!props.virtual && isOpen.value && activeIndex.value >= 0) {
const list = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rozie-combobox-list') : null;
const opt = list ? list.querySelector('#' + optId(activeIndex.value)) : null;
if (opt) opt.scrollIntoView({
block: 'nearest'
});
return;
}
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();
};
// idRoot(): the id base — the `idBase` prop, else the per-instance id generated
// in $onMount (`autoId`), else the pre-mount fallback. Generated after mount (not
// during setup) so a server render and the hydrating client agree.
const idRoot = () => props.idBase || autoId.value || 'rozie-combobox';
const optId = (i: any) => idRoot() + '-opt-' + i;
const listId = () => idRoot() + '-list';
// popupVisible() (hideEmpty, COMBOBOX-SPEC item 4): whether the popup is actually
// SHOWN — open AND (unless `hideEmpty`) something to render. With `hideEmpty` an
// open popup with no option rows AND no create row counts as hidden: the list
// branches do not render, aria-expanded reports false, and Escape is left to the
// host (B4). Without `hideEmpty` this is exactly `$data.isOpen` (byte-identical-off).
const popupVisible = () => {
if (!isOpen.value) return false;
if (!props.hideEmpty) return true;
return navRows().length > 0;
};
// The active option's id for aria-activedescendant (null when none).
const activeId = () => {
const list = navRows();
if (popupVisible() && activeIndex.value >= 0 && list[activeIndex.value]) return optId(activeIndex.value);
return null;
};
// activeOption() (handle verb, COMBOBOX-SPEC item 8): the highlighted RAW source
// option, or null (nothing highlighted, the popup is hidden, or the highlighted
// row is a synthetic "+N more" / create row).
const activeOption = () => {
const list = navRows();
const ai = activeIndex.value;
if (!popupVisible() || ai < 0) return null;
const row = list[ai];
if (!row || row.isMore || row.isCreate) return null;
return row.option === undefined ? null : row.option;
};
// 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;
};
// ---- multi-select membership + effective-default helpers (Phase 86 R1) -----
// Ported from @rozie-ui/headless-core/listCore.rzts's select()/isSelected()
// algorithm (also shipped, verbatim, via @rozie-ui/listbox) — PORTED, not
// imported: combobox's own open/active/query state machine is deliberately
// host-local (see the header comment above), and listCore.rzts is also
// consumed by the release-ignored listbox family, so pulling this into the
// shared partial would put listbox's frozen leaves back in scope.
//
// selectedValues(): the current selection as a de-duplicated array, tolerant
// of a null/undefined model. De-duplicates the MODEL array itself (not just
// `options`) so a re-normalized selection never reports the same value twice
// even if the model ever ends up holding a duplicate.
const selectedValues = () => {
const cur = value.value;
const arr = Array.isArray(cur) ? cur : [];
return Array.from(new Set(arr));
};
// isRowSelected(row): array membership under `multiple`, strict equality
// otherwise. Replaces every raw `opt.value === $props.value` / `wr.row.value
// === $props.value` template comparison (task 2) so all four render branches
// share exactly ONE membership check and can never disagree.
const isRowSelected = (row: any) => {
if (!row) return false;
if (props.multiple) return selectedValues().indexOf(row.value) !== -1;
return row.value === value.value;
};
// effectiveCloseOnSelect(): resolves the `closeOnSelect` sentinel (see the
// prop's own doc comment above for why the prop's default is `null`, not a
// literal `true`). Unset ⇒ `true` in single-select (today's default,
// unchanged), `false` under `multiple`; an explicit `true`/`false` from the
// consumer always wins in either mode. Every existing `closeOnSelect` read
// routes through this helper so the four render branches cannot disagree.
const effectiveCloseOnSelect = () => {
const v = props.closeOnSelect;
if (v === true || v === false) return v;
return !props.multiple;
};
// chipsInline() (chipLayout, COMBOBOX-SPEC item 2): chips + input on one
// wrapping row — only meaningful under `multiple`.
const chipsInline = () => !!props.multiple && props.chipLayout === 'inline';
// ---- chip rail (Phase 86 R1, plan 86-05, D-13/D-16/D-18) ---------------
// chipRows(): selectedValues() (already de-duplicated — see above) mapped to
// chip-rail display rows. Each row carries the raw source `option` when it is
// still present in `options` (mirroring how filteredOptions() attaches the raw
// option to every wrapper row), or a raw-value fallback label when the option
// has disappeared from an asynchronously swapped `options` array — the locked
// R1 concurrency edge: an orphan chip persists, labelled by its raw value,
// rather than vanishing. `value` array order IS chip display order (R1
// locked); selectedValues() already preserves it.
const chipRows = () => {
const opts = Array.isArray(props.options) ? props.options : [];
return selectedValues().map((v: any) => {
const found = opts.find((o: any) => valueOf(o) === v);
return found ? {
value: v,
label: labelOf(found),
option: found
} : {
value: v,
label: String(v),
option: null
};
});
};
// chipRemoveLabel(row): the aria-label naming what a chip's remove control removes.
const chipRemoveLabel = (row: any) => 'Remove ' + String(row.label);
// removeChipValue(v) is defined AFTER selectOption() below (not here) — React's
// emitter derives each `useCallback`'s static dependency array from the
// helpers its body calls, and `removeChipValue` calls `selectOption`. Declaring
// it before `selectOption`'s own `const` would put `selectOption` in
// `removeChipValue`'s deps array ahead of its OWN initializer in the SAME
// module scope — a real same-render TDZ (`ReferenceError` at runtime on
// React, TS2448 "used before its declaration" at typecheck). Source order
// here IS emission order for these plain top-level consts, so
// `removeChipValue` must textually follow `selectOption`.
// ---- 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`). `effectiveCloseOnSelect()` gates the popup close.
const selectOption = (opt: any) => {
if (!opt) return;
if (opt.isMore) {
expandGroup(opt.group);
activeIndex.value = opt._i;
return;
}
if (opt.isCreate) {
// Read locals before any write (ROZ138 idiom).
const q = inputText.value;
const nq = normalizedQuery();
// The double-commit latch (D-17/D-20): a second commit of the SAME
// normalized query — whether a rapid double gesture, or the async
// round-trip window before the consumer's `options` update lands — is a
// no-op. An empty/whitespace normalized query never emits either (the
// row should not even be reachable then, since isCreatableQuery() gates
// it, but this guard is cheap insurance against a stale reference).
if (!nq || nq === createdQuery.value) return;
createdQuery.value = nq;
emit('create', {
query: q
});
// D-20: after `create` fires, local UI state behaves like a pick — the
// effective close-on-select applies, and the query clears in `multiple`
// mode (ready for the next entry) and is left alone in single mode (the
// consumer's async add flows back through the ordinary `value` watch).
// `value` itself is untouched — R3 locked.
if (effectiveCloseOnSelect()) isOpen.value = false;
if (props.multiple) clearQuery(null);
activeIndex.value = -1;
return;
}
if (opt.disabled) return;
if (props.multiple) {
// Capture whether the value was already present BEFORE the toggle — this
// local is what feeds the `selected` field on the `change` payload (D-15).
const cur = selectedValues();
const wasSelected = cur.indexOf(opt.value) !== -1;
// Fresh array on every commit — in-place mutation (.push/.splice) is
// silently dropped by the React/Solid/Lit/Angular change detectors.
const next = wasSelected ? cur.filter((v: any) => v !== opt.value) : [...cur, opt.value];
value.value = next;
// D-14: clear the query on pick under `multiple` (not the option's label)
// so Backspace-removes-last stays reachable immediately after a pick.
// `opt.isRemoval` (set only by removeChipValue() below) skips this —
// removing a chip is not a pick, and clobbering whatever the user was
// mid-typing in the search box is a separate, unrelated data loss.
if (!opt.isRemoval) clearQuery(null);
if (effectiveCloseOnSelect()) isOpen.value = false;
activeIndex.value = -1;
emit('change', {
value: next,
option: opt.option,
selected: !wasSelected
});
return;
}
value.value = opt.value;
inputText.value = String(opt.label);
if (effectiveCloseOnSelect()) isOpen.value = false;
activeIndex.value = -1;
// D-15: `selected` is additive and always `true` in single-select.
emit('change', {
value: opt.value,
option: opt.option,
selected: true
});
};
// removeChipValue(v): routes chip removal through the EXACT SAME toggle path
// selectOption() uses for a re-select — a synthetic wrapper row is enough,
// since the `multiple` branch above only reads `opt.value`/`opt.option`/
// `opt.disabled`/`opt.isMore` — so removal and toggle-off can never diverge
// into different payload shapes. Declared here, after selectOption(), not
// alongside chipRows()/chipRemoveLabel() above — see the comment there.
const removeChipValue = (v: any) => {
const opts = Array.isArray(props.options) ? props.options : [];
const found = opts.find((o: any) => valueOf(o) === v);
// isRemoval: true tells selectOption()'s `multiple` branch this is a
// removal, not a pick — see the D-14 comment there.
selectOption({
value: v,
option: found || null,
isRemoval: true
});
};
// onChipRemovePointerDown() (quick-260903-0s1, E1 audit finding): the POINTER
// half of the chip remove control's split binding. Deliberately empty —
// the `.prevent` modifier this is bound to (mousedown) is its ENTIRE payload:
// preventDefault on mousedown suppresses the native focus shift, which is
// what keeps the input focused, keeps onBlur() from firing, and therefore
// keeps the popup open (the CR-02 hazard commit `d02a145ef` closed). The
// removal deliberately does NOT live here: preventDefault on mousedown does
// NOT suppress the click that follows it, so a handler bound to BOTH events
// would remove the chip twice per pointer press. See onChipRemoveActivate()
// below for where the removal actually happens.
const onChipRemovePointerDown = () => {};
// onNativeInputChange() (release-0.8.0): the `.stop` on the input's native
// `change` is its whole payload — the native event bubbles out of the inner
// <input> on blur after an edit, and on Angular (no shadow boundary) a consumer
// `(change)` binding on <rozie-combobox> would receive that DOM Event as well as
// the component's own `change` output (the same collision popover's audit B6
// removed). Stopping it keeps `change` meaning only the component event.
const onNativeInputChange = () => {};
// onChipRemoveActivate(v) (quick-260903-0s1, E1 audit finding): the CLICK half
// of the split binding — the actual removal. `click` is the one event every
// activation path produces: a real pointer press (mousedown+click), Enter or
// Space on the focused button (native <button> behavior fires `click`, never
// `keydown`-observable-as-such), AND a screen reader's synthesized activation
// (which emits `click` with no preceding `mousedown` at all — the E1 defect
// this fixes). Binding removal to `click` alone covers all three with exactly
// one removal per activation.
//
// Keyboard/AT activation puts DOM focus ON the button, which this removal
// then unmounts — without an explicit refocus, focus would fall to
// `document.body`. Restore it using the EXACT idiom onFocus() above already
// uses (proven on all six targets): a queued microtask that refocuses
// `$refs.inputEl` only when it exists and is not already `document.activeElement`.
// That activeElement guard is what makes this a strict no-op on the pointer
// path — a pointer press never moves focus off the input in the first place
// (onChipRemovePointerDown's preventDefault sees to that), so this refocus
// never re-enters onFocus() and never re-selects the in-progress query.
// $refs is safe here for the same reason it is safe everywhere else in this
// file: this is a post-mount event handler, not module-init code.
//
// `.stop` on the template's `@click` binding (real-browser VR finding,
// quick-260903-0s1): on Solid and Svelte specifically — the two targets whose
// reactivity applies a DOM mutation SYNCHRONOUSLY, inside the very handler
// that triggered it, rather than batched to a microtask like the other four
// — removing this chip's own `<li>` mid-click detaches the click event's
// `target` from the document BEFORE the event finishes bubbling. Popover's
// own document-level `@click.outside($refs.anchorEl,$refs.floatingEl)`
// dismiss listener (Popover.rozie) then evaluates `anchorEl.contains(target)`
// against the NOW-DETACHED target, which is unconditionally `false` for any
// detached node — misreading this internal removal as an outside click and
// closing the popup. `.stop` (stopPropagation) keeps this click from ever
// reaching that document listener, exactly like the sibling `@mousedown.stop`
// pattern command-palette's own action-menu-affordance row already uses to
// keep an inner gesture from bubbling into an ancestor's own listener.
const onChipRemoveActivate = (v: any) => {
removeChipValue(v);
queueMicrotask(() => {
if (inputElRef.value && document.activeElement !== inputElRef.value) inputElRef.value!.focus();
});
};
// Reflect the externally-selected value into the input text. D-14: no-ops
// under `multiple` — there is no single label to mirror into the input once
// `value` holds an array, and the query is owned by chip-picking instead.
//
// quick-260903-0s1 (E2 audit finding): routed through the SAME valueOf()/
// labelOf() resolvers every other option read in this file uses
// (filteredOptions(), chipRows(), removeChipValue(), queryMatchesOption()) —
// this was the single site that still read the raw `.value`/`.label`
// properties directly. `optionValue`/`optionLabel` are documented public
// props, and the resolvers additionally carry the primitive-option fallback
// (`String(opt)` when `opt` has no `.label`) — bypassing them blanked the
// input on both the mount path ($onMount → syncQueryToValue()) and the
// external-value path ($watch(() => $props.value, ...) → syncQueryToValue()).
//
// The "not found" guard is on `opt` being neither `undefined` NOR `null`,
// deliberately not on truthiness: with primitive options the found entry IS
// the option, so a legitimate selection of an empty string or a zero would be
// discarded by a truthiness test and re-blank the input — reintroducing the
// bug in a new shape. `Array.prototype.find` returns `undefined` on a miss,
// so that is the correct miss test; the `null` check keeps a `null` option
// from rendering as the literal text "null".
const syncQueryToValue = () => {
if (props.multiple) return;
const opts = Array.isArray(props.options) ? props.options : [];
const opt = opts.find((o: any) => valueOf(o) === value.value);
inputText.value = opt === undefined || opt === null ? '' : String(labelOf(opt));
};
// ---- free-text commits (COMBOBOX-SPEC items 5-7, multiple only) --------
// delimiterList(): the `delimiters` prop normalized to an array.
const delimiterList = () => Array.isArray(props.delimiters) ? props.delimiters : [];
// splitDelimiters(): the CHARACTER delimiters (everything but 'Enter'/'Tab') —
// the paste split characters.
const splitDelimiters = () => delimiterList().filter((k: any) => k !== 'Enter' && k !== 'Tab');
// freeTextOn(): free-text commits are enabled under `multiple` when a delimiter
// list, a validate function, a splitPaste function or commitOnBlur is supplied.
const freeTextOn = () => !!props.multiple && (delimiterList().length > 0 || typeof props.validate === 'function' || typeof props.splitPaste === 'function' || !!props.commitOnBlur);
// storedText(t): the `validate` gate + normaliser (Tags' shape), for an already
// trimmed, non-empty `t`. Returns the string to store, or null when rejected:
// absent validate ⇒ t; a string return ⇒ that string ('' rejects); any other
// truthy return (`true`) ⇒ t; a falsy return ⇒ rejected.
const storedText = (t: any) => {
if (typeof props.validate !== 'function') return t;
const r = props.validate(t);
if (!r) return null;
return typeof r === 'string' ? r : t;
};
// commitTexts(texts): append every not-yet-present text to `value` (ONE fresh
// array, ONE model write) and emit one `change` per committed text, each with the
// running array as of that commit. Texts already present are skipped silently.
const commitTexts = (texts: any) => {
let next = selectedValues();
const committed = [];
const snapshots = [];
for (let i = 0; i < texts.length; i++) {
const t = texts[i];
if (next.indexOf(t) !== -1) continue;
next = next.concat([t]);
committed.push(t);
snapshots.push(next);
}
if (committed.length > 0) value.value = next;
activeIndex.value = -1;
for (let i = 0; i < committed.length; i++) {
emit('change', {
value: snapshots[i],
option: null,
selected: true,
text: committed[i]
});
}
};
// syncInputText(el, text): also write the LIVE input element. Angular compares a
// `[value]` binding against its last RENDERED value: fast typing followed by a
// commit in the same frame (before change detection rendered the typed text)
// leaves query '' === last-rendered '' — no DOM write, the typed text stays.
// Writing the element directly is idempotent on every other target.
const syncInputText = (el: any, text: any) => {
if (el && typeof el.value === 'string' && el.value !== text) el.value = text;
};
// setTypedText(q, el): the input text changed to `q` — by typing (onInput) or by a
// paste Combobox handled itself (insertAtCaret). Re-arms the create latch, opens
// the list, highlights the first row and emits `search`, exactly as typing does.
const setTypedText = (q: any, el: any) => {
inputText.value = q;
syncInputText(el, q);
// Any input change re-arms the double-commit latch (D-17/D-20) — a
// freshly-typed query is a new gesture, never a repeat of whatever was
// last created.
createdQuery.value = null;
isOpen.value = true;
activeIndex.value = 0;
emit('search', {
query: q
});
};
// clearQuery(el): Combobox clearing the input text ITSELF (a pick under
// `multiple`, a create under `multiple`, a free-text commit, clear()). Emits
// `search` with '' so a host tracking the query through `search` never goes
// stale — a free-text commit of an already-selected value fires no `change`,
// so this is the host's only signal. No emit when the text was already empty.
// The live element is consulted too: on React a commit in the same frame as the
// last keystroke still sees the pre-keystroke `inputText` in its closure.
const clearQuery = (el: any) => {
const had = inputText.value !== '' || !!(el && typeof el.value === 'string' && el.value !== '');
inputText.value = '';
syncInputText(el, '');
if (had) emit('search', {
query: ''
});
};
// insertAtCaret(el, text): insert `text` into the input at the caret, replacing
// the selection — what an ordinary paste does — and leave the caret after it.
const insertAtCaret = (el: any, text: any) => {
const cur = el && typeof el.value === 'string' ? el.value : String(inputText.value);
const start = el && typeof el.selectionStart === 'number' ? el.selectionStart : cur.length;
const end = el && typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
const next = cur.slice(0, start) + text + cur.slice(end);
setTypedText(next, el);
const caret = start + text.length;
if (el && typeof el.setSelectionRange === 'function') el.setSelectionRange(caret, caret);
};
// commitFreeText(raw, el): trim → validate (normalise) → commit + clear the input.
// Returns true when the text was handled (committed, or already present ⇒ just
// cleared); false when empty or rejected — rejected text stays in the input.
const commitFreeText = (raw: any, el: any) => {
const t = String(raw == null ? '' : raw).trim();
if (!t) return false;
const stored = storedText(t);
if (stored === null) return false;
clearQuery(el);
commitTexts([stored]);
return true;
};
// splitOnDelimiters(text): the built-in paste split — the clipboard text split on
// every CHARACTER delimiter, or null when it contains none (an ordinary paste).
const splitOnDelimiters = (text: any) => {
const seps = splitDelimiters();
let hasSep = false;
for (let s = 0; s < seps.length; s++) {
if (text.indexOf(seps[s]) !== -1) hasSep = true;
}
if (!hasSep) return null;
let parts = [text];
for (let s = 0; s < seps.length; s++) {
const out = [];
for (let p = 0; p < parts.length; p++) {
const pieces = String(parts[p]).split(seps[s]);
for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
}
parts = out;
}
return parts;
};
// onPaste(e) (item 6): under free-text mode the clipboard text is split — by
// `splitPaste` when supplied, else on the character delimiters — and every
// non-empty trimmed part `validate` accepts is committed (the paste is
// preventDefault-ed). The rejected parts (joined by the first delimiter) are
// inserted at the caret, replacing the selection, as an ordinary paste would be,
// so text typed before the paste is kept. A split of null (splitPaste said "not
// mine", or no delimiter in the text) leaves the paste to the browser.
const onPaste = (e: any) => {
if (!freeTextOn()) return;
const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
// typeof checked inline (not via a local flag) so strict TS narrows the call.
const split = typeof props.splitPaste === 'function' ? props.splitPaste(text) : splitOnDelimiters(text);
if (!Array.isArray(split)) return;
if (e) e.preventDefault();
const accepted = [];
const rejected = [];
for (let i = 0; i < split.length; i++) {
const part = String(split[i] == null ? '' : split[i]).trim();
if (!part) continue;
const stored = storedText(part);
if (stored === null) rejected.push(part);else accepted.push(stored);
}
const seps = splitDelimiters();
const rest = rejected.join(seps.length > 0 ? seps[0] + ' ' : ' ');
if (rest) insertAtCaret(e ? e.target : null, rest);
commitTexts(accepted);
};
// ---- input + keyboard handlers -----------------------------------------
const onInput = (e: any) => {
const q = e && e.target ? e.target.value : '';
setTypedText(q, null);
};
const onFocus = (e: any) => {
// Phase 86 R2 (plan 86-03), Solid-only reentrancy guard: the input now
// renders inside the composed popover's SCOPED `#anchor` slot
// (`:open="$props.open"` among its params — see the <Popover> template
// comment for why the input moved there). On Solid, a named slot invocation
// with reactive scope params is a plain closure CALL re-run whenever any
// param changes (@rozie/core's documented, intentional Solid
// slot-reactivity design — not a bug to route around at the emitter level):
// the `isOpen` write below changes the `open` param this exact handler is
// responding to, which on Solid SYNCHRONOUSLY recreates the anchor's DOM
// subtree (Solid's JSX has no virtual-DOM diffing to preserve node identity
// across a closure re-invocation) — removing the just-focused `<input>`
// fires a NATIVE blur on it, mid-call-stack, before this function even
// returns. Without the guard below, that blur's own onBlur() would
// immediately set isOpen back to false, and the deferred re-focus further
// down would restart the SAME cycle on the fresh node — an infinite
// recreate/blur/close/refocus loop. `openingInProgress` (below) tells
// onBlur "this blur is a side effect of OUR OWN isOpen write, not the user
// moving focus away" so it can skip closing. The other 5 targets diff their
// scoped-slot re-render and keep the existing, already-focused node — no
// blur ever fires there, so the guard is a no-op for them.
// disableOpenOnFocus (item 3): focus alone never opens the list — typing
// (onInput) and ArrowDown/ArrowUp (onKeydown) still do.
if (props.disableOpenOnFocus) {
if (e && e.target && e.target.select) e.target.select();
return;
}
openingInProgress = true;
isOpen.value = true;
// Cleared SYNCHRONOUSLY, immediately after the write — Solid's reactive
// cascade (if any) runs SYNCHRONOUSLY as part of that write, before this
// line executes, so the guard window covers exactly the recreate/blur
// cascade and nothing past it. A deferred (microtask) clear would leave a
// stale `true` window spanning an `await` boundary whenever the re-focus
// below re-enters onFocus, incorrectly suppressing a LATER, genuine blur.
openingInProgress = false;
if (e && e.target && e.target.select) e.target.select();
queueMicrotask(() => {
// Re-assert focus onto whatever node is CURRENT — after Solid's
// synchronous signal-write reactivity (if any) has already run and
// `$refs.inputEl` reflects the latest node — recovering focus if it was
// stranded on a since-removed one.
if (inputElRef.value && document.activeElement !== inputElRef.value) inputElRef.value!.focus();
});
};
// @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.
// While `pinned` (pinOpen(true)), early-return BEFORE the isOpen write — a host
// sub-surface (e.g. command-palette's action flyout) is holding focus and the
// popup must stay open until the host calls pinOpen(false) itself. While
// `openingInProgress` (Solid-only, see onFocus above), early-return too — this
// blur is a side effect of our OWN open-transition recreating the anchor's DOM,
// not the user moving focus elsewhere.
// commitOnBlur: leaving the field commits the typed text through validate (a blur
// into a pinned host sub-surface, or the Solid recreate blur, returned above).
const onBlur = (e: any) => {
if (pinned.value) return;
if (openingInProgress) return;
isOpen.value = false;
if (props.commitOnBlur && freeTextOn()) {
const el = e ? e.target : null;
commitFreeText(el ? el.value : inputText.value, el);
}
};
const onKeydown = (e: any) => {
// B10: ignore every key while an IME composition is active — the Enter that
// confirms a composition must never pick, commit or navigate. Read through
// `nativeEvent` when present: React's synthetic keyboard event does not carry
// `isComposing` (every other target hands the native event straight through).
const ne = e && e.nativeEvent ? e.nativeEvent : e;
if (ne && (ne.isComposing || ne.keyCode === 229)) return;
const key = e ? e.key : '';
const list = navRows();
// 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;
const visible = popupVisible();
const liveText = e && e.target ? e.target.value : '';
const highlighted = wasOpen && ai >= 0 && list[ai] ? list[ai] : null;
// Character delimiters (item 5): commit the TYPED text — never the highlighted
// option. 'Enter' / 'Tab' entries are handled in their own branches below.
if (freeTextOn() && key !== 'Enter' && key !== 'Tab' && delimiterList().indexOf(key) !== -1) {
if (e) e.preventDefault();
commitFreeText(liveText, e ? e.target : null);
return;
}
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') {
// B9: Enter with Ctrl / Meta / Alt is left to the host (e.g. a send shortcut).
const modified = !!(e && (e.ctrlKey || e.metaKey || e.altKey));
if (!modified) {
if (highlighted) {
if (e) e.preventDefault();
selectOption(highlighted);
} else if (freeTextOn() && String(liveText).trim()) {
// Free-text mode (item 7): Enter with no highlighted option commits the
// typed text (rejected text stays in the input).
if (e) e.preventDefault();
commitFreeText(liveText, e ? e.target : null);
}
}
} else if (key === 'Tab') {
// selectOnTab (item 8): pick the highlighted option while the popup is
// visible; preventDefault ONLY when it picked. A 'Tab' delimiter commits the
// typed text when nothing was picked. Otherwise Tab moves focus normally.
if (props.selectOnTab && visible && highlighted && !highlighted.disabled) {
if (e) e.preventDefault();
selectOption(highlighted);
} else if (freeTextOn() && delimiterList().indexOf('Tab') !== -1 && String(liveText).trim()) {
if (commitFreeText(liveText, e ? e.target : null) && e) e.preventDefault();
}
} else if (key === 'Escape') {
// B4: only consume Escape when the popup is actually VISIBLE.
if (visible) {
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);
}
} else if (key === 'Backspace') {
// Backspace-removes-last-chip (Tags.rozie precedent, Phase 86 R1 plan
// 86-05): guarded on `multiple` AND the LIVE input value being empty —
// read `e.target.value` directly (Tags' proven idiom), never the mirrored
// `$data.inputText`. A non-empty query falls through to normal text editing —
// nothing here removes a chip while there is text to delete.
if (props.multiple) {
const liveValue = e && e.target ? e.target.value : '';
if (liveValue === '') {
const cur = selectedValues();
if (cur.length > 0) {
if (e) e.preventDefault();
removeChipValue(cur[cur.length - 1]);
}
}
}
}
// Keep the (new) active option in view — routes through the virtualizer when
// windowing, direct scrollIntoView otherwise.
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: 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);
}
};
// buildVirtualizer() (combobox-virtual-reactivity, VIRT-BUILD): the SINGLE virtualizer
// construction site — called from $onMount below (mount-time virtual:true) AND from the
// virtual $watch further down (a runtime false→true flip), so the mount path can never
// drift from the flip path. Guarded so a build queued (rAF-deferred by the $watch) that
// fires AFTER a flip-back is a no-op (rapid-flip idempotence), and so calling it twice
// never double-constructs.
const buildVirtualizer = () => {
if (!props.virtual || virtualizer) return;
// 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. 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.
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);
};
// teardownVirtualizer() (VIRT-TEARDOWN): runs the SAME per-instance cleanup fn
// $onUnmount invokes below, then nulls the instance state + bumps windowVer so the
// windowed template branch (still mounted while $props.virtual — CR-01) re-derives to
// the pre-construction fallback state instead of holding a stale virtualizer. This is
// the true→false ResizeObserver-leak fix: previously ONLY $onUnmount ever called
// virtualizerCleanup, so a runtime flip to non-virtual left the observer live.
const teardownVirtualizer = () => {
if (virtualizerCleanup) virtualizerCleanup();
virtualizer = null;
virtualizerCleanup = null;
gridScrollEl = null;
windowVer.value = windowVer.value + 1;
};
// nextAutoId(): a page-wide counter shared by every Rozie component instance. It
// lives on globalThis (read through Reflect, which type-checks in the plain-JS and
// the TS script alike) so separately bundled copies of a leaf never hand out the
// same id. The same four lines live in Combobox, Listbox and Popover.
const nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
};
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. seedQuery(text) — imperative-only: write the input text
// (and therefore filteredOptions()'s filter) without touching the `value`
// model or selection state (a command-palette #2 levels/restore-on-pop
// prerequisite — repopulating the input on back-navigation is NOT a
// selection). pinOpen(v) — imperative-only: pin (or unpin) the popup open so
// onBlur() does not collapse it while a host sub-surface holds focus, AND
// (Phase 86-07 regression fix) so the composed Popover's OWN independent
// Escape/click-outside dismissal is vetoed too via `:disable-dismiss`
// (command-palette-sub-actions prerequisite). pinOpen(false) ONLY unpins — it
// does NOT itself close the popup or move focus; that is the host's job.
// Render-neutral when never called. All four are post-mount → $refs safe.
const focus = () => inputElRef.value?.focus();
const clear = () => {
// Fresh empty array under `multiple` (never in-place mutation), null in
// single mode — mirrors selectOption()'s `{ value, option, selected }`
// shape; nothing is selected after a clear, so `selected` is `false`.
const empty = props.multiple ? [] : null;
value.value = empty;
clearQuery(null);
activeIndex.value = -1;
emit('change', {
value: empty,
option: null,
selected: false
});
};
const seedQuery = (text: any) => {
inputText.value = String(text == null ? '' : text);
};
const pinOpen = (v: any) => {
pinned.value = !!v;
};
// query() — the current input text (what the last `search` reported).
const query = () => inputText.value;
onMounted(() => {
if (!props.idBase) autoId.value = 'rozie-combobox-' + nextAutoId();
syncQueryToValue();
syncRows();
didMount = true;
// Routes through the SAME buildVirtualizer() the virtual $watch calls below
// (VIRT-BUILD) — one construction site, so the mount path cannot drift from the flip
// path.
if (props.virtual) buildVirtualizer();
});
onBeforeUnmount(() => {
if (virtualizerCleanup) virtualizerCleanup();
});
watch(() => value.value, () => {
syncQueryToValue();
}, { flush: 'post' });
watch(() => (props.options ? props.options.length : 0) + '|' + inputText.value, () => {
if (expandedGroups.value && Object.keys(expandedGroups.value).length) expandedGroups.value = {};
syncRows();
if (props.virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
windowVer.value = windowVer.value + 1;
scheduleRemeasure();
}
}, { flush: 'post' });
watch(() => props.virtual, () => {
if (expandedGroups.value && Object.keys(expandedGroups.value).length) expandedGroups.value = {};
if (props.virtual) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => buildVirtualizer());else setTimeout(() => buildVirtualizer(), 0);
} else {
teardownVirtualizer();
}
}, { flush: 'post' });
defineExpose({ focus, clear, seedQuery, pinOpen, activeOption, query } as ComboboxHandle);
</script>
<style scoped>
.rozie-combobox {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input {
box-sizing: border-box;
/* Phase 86 R2 (plan 86-03): EXPLICIT width, not `100%`. The input now renders
inside popover's `.rozie-popover-anchor` (`display: inline-block`,
shrink-to-fit) rather than as a direct 100%-width child of `.rozie-combobox`
(`width: var(--rozie-combobox-width, var(--rcb-width, 16rem))`) — a percentage width here would
be circular against that shrink-to-fit ancestor (CSS 2.1 §10.3.3: an
unresolvable percentage against an auto-width parent degrades to the
intrinsic/auto size, NOT the control's real width), which is exactly the
bug this fixes: `anchorEl`'s measured rect must equal the input's real box
for Floating UI's positioning AND `matchWidth`'s reference width to be
correct. Reads the SAME `--rozie-combobox-width` token `.rozie-combobox`
itself uses, so the rendered pixel width is IDENTICAL to before this change
in the default (non-inline) case. `.rozie-combobox--inline
.rozie-combobox-input` below restores `100%` for the inline pass-through
path, where `.rozie-combobox` itself stretches to its container (unaffected
by this fix — `disablePositioning` skips anchor measurement entirely there). */
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
padding: var(--rozie-combobox-input-padding, var(--rcb-input-padding, 0.5rem 0.75rem));
font: inherit;
color: var(--rozie-combobox-color, var(--rcb-color, inherit));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
/*
Render-neutral bottom-divider token (260715-50l finding 3). A longhand
AFTER the `border:` shorthand above so it wins on the bottom side; the
fallback REPLICATES the shorthand's own bottom (border-width solid
border-color) so default rendering is byte-for-render unchanged. Lets a
consumer (e.g. command-palette) render a borderless-with-underline input
without touching the other three sides.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25))));
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input:focus {
/* Decoupled from --rozie-combobox-accent (finding 3) so a consumer can */
/* neutralize the focus BORDER without touching the selected-option accent. */
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
/*
Same underline token, focus-colored fallback — the longhand keeps
WINNING on the bottom side over the :focus border-color override above,
so a consumer-set divider survives both blurred and focused states.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc))));
}
.rozie-combobox--disabled .rozie-combobox-input {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, var(--rcb-disabled-opacity, 0.55));
background: var(--rozie-combobox-disabled-bg, var(--rcb-disabled-bg, rgba(0, 0, 0, 0.04)));
}
.rozie-combobox-list {
margin: 0;
padding: var(--rozie-combobox-list-padding, var(--rcb-list-padding, 0.25rem));
list-style: none;
max-height: var(--rozie-combobox-list-max-height, var(--rcb-list-max-height, 16rem));
overflow-y: auto;
background: var(--rozie-combobox-list-bg, var(--rcb-list-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-list-border-color, var(--rcb-list-border-color, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
box-shadow: var(--rozie-combobox-list-shadow, var(--rcb-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16)));
}
.rozie-combobox-option {
padding: var(--rozie-combobox-option-padding, var(--rcb-option-padding, 0.4rem 0.6rem));
border-radius: var(--rozie-combobox-option-radius, var(--rcb-option-radius, 0.375rem));
cursor: pointer;
color: var(--rozie-combobox-option-color, inherit);
}
.rozie-combobox-option--active {
background: var(--rozie-combobox-option-active-bg, var(--rcb-option-active-bg, rgba(0, 102, 204, 0.12)));
}
.rozie-combobox-option--selected {
font-weight: var(--rozie-combobox-option-selected-weight, var(--rcb-option-selected-weight, 600));
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, var(--rcb-option-selected-color, var(--rcb-accent, #0066cc))));
}
.rozie-combobox-option--disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-empty {
padding: var(--rozie-combobox-empty-padding, var(--rcb-empty-padding, 0.5rem 0.6rem));
color: var(--rozie-combobox-empty-color, var(--rcb-empty-color, rgba(0, 0, 0, 0.5)));
list-style: none;
}
.rozie-combobox-group {
list-style: none;
}
.rozie-combobox-group-heading {
/* Render-neutral section-separation token (260715-50l finding 4) — default */
/* 0 = unchanged; a consumer-set value separates the leading ungrouped */
/* block from the first group heading. */
margin-top: var(--rozie-combobox-group-heading-margin-top, var(--rcb-group-heading-margin-top, 0));
padding: var(--rozie-combobox-group-heading-padding, var(--rcb-group-heading-padding, 0.35rem 0.6rem 0.15rem));
font-size: var(--rozie-combobox-group-heading-size, var(--rcb-group-heading-size, 0.75rem));
font-weight: var(--rozie-combobox-group-heading-weight, var(--rcb-group-heading-weight, 600));
text-transform: var(--rozie-combobox-group-heading-transform, var(--rcb-group-heading-transform, uppercase));
letter-spacing: var(--rozie-combobox-group-heading-letter-spacing, var(--rcb-group-heading-letter-spacing, 0.03em));
color: var(--rozie-combobox-group-heading-color, var(--rcb-group-heading-color, rgba(0, 0, 0, 0.5)));
pointer-events: none;
user-select: none;
}
.rozie-combobox-more {
cursor: pointer;
color: var(--rozie-combobox-more-color, var(--rcb-more-color, rgba(0, 0, 0, 0.55)));
font-size: var(--rozie-combobox-more-size, var(--rcb-more-size, 0.875rem));
}
.rozie-combobox-create {
cursor: pointer;
color: var(--rozie-combobox-create-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
background: var(--rozie-combobox-create-bg, var(--rcb-create-bg, transparent));
}
.rozie-combobox-spacer { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox-list--virtual { overflow-anchor: none; }
.rozie-combobox-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
padding: var(--rozie-combobox-chips-padding, var(--rcb-chips-padding, 0.35rem 0.45rem 0 0.45rem));
margin: 0;
list-style: none;
}
.rozie-combobox-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: var(--rozie-combobox-chip-padding, var(--rcb-chip-padding, 0.15rem 0.5rem));
font-size: var(--rozie-combobox-chip-size, var(--rcb-chip-size, 0.85rem));
color: var(--rozie-combobox-chip-color, inherit);
background: var(--rozie-combobox-chip-bg, var(--rcb-chip-bg, rgba(0, 102, 204, 0.12)));
border-radius: var(--rozie-combobox-chip-radius, var(--rcb-chip-radius, 0.375rem));
white-space: nowrap;
}
.rozie-combobox-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
height: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
padding: 0;
font: inherit;
line-height: 1;
color: var(--rozie-combobox-chip-remove-color, var(--rcb-chip-remove-color, currentColor));
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
transition: color 0.15s;
}
.rozie-combobox-chip__remove:hover:not(:disabled) {
color: var(--rozie-combobox-chip-remove-hover-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
}
.rozie-combobox-chip__remove:disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-control {
display: contents;
}
.rozie-combobox--block {
display: block;
width: 100%;
container-type: inline-size;
}
.rozie-combobox--block .rozie-combobox-control {
display: block;
width: 100cqw;
}
.rozie-combobox--block .rozie-combobox-input {
width: 100%;
}
.rozie-combobox--chips-inline {
container-type: inline-size;
}
.rozie-combobox--chips-inline .rozie-combobox-control {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
width: 100cqw;
padding: var(--rozie-combobox-inline-padding, var(--rcb-inline-padding, 0.3rem 0.45rem));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox--chips-inline .rozie-combobox-control:focus-within {
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
}
.rozie-combobox--chips-inline .rozie-combobox-chips {
display: contents;
}
.rozie-combobox--chips-inline .rozie-combobox-input,
.rozie-combobox--chips-inline .rozie-combobox-input:focus {
flex: 1 1 var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
width: auto;
min-width: var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
padding: var(--rozie-combobox-inline-input-padding, var(--rcb-inline-input-padding, 0.2rem 0.25rem));
background: transparent;
border: none;
box-shadow: none;
}
.rozie-combobox--inline {
display: block;
width: 100%;
}
.rozie-combobox--inline .rozie-combobox-list {
/* `position: static` dropped (plan 86-03): `.rozie-combobox-list` carries no
absolute positioning to undo anymore — that geometry lives on popover's
`.rozie-popover-floating`, and `:disable-positioning="$props.inline"`
(D-09) already renders it as a static pass-through via popover's own
`.rozie-popover-floating--static` rule. */
margin-top: var(--rozie-combobox-list-gap, var(--rcb-list-gap, 0.25rem));
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-combobox--inline .rozie-combobox-input {
width: 100%;
}
</style>svelte
<script module lang="ts">
// The typed public surface (typed-surface P1; always TypeScript). `value` /
// `option` stay `any`: options are consumer-shaped objects (or primitives) the
// component never inspects beyond the label/value/disabled resolvers.
/** `search` payload — the current input text. */
export interface ComboboxSearchPayload {
query: string;
}
/** `change` payload — `option` is the raw source option (`null` for a clear or a free-text commit); `text` is set ONLY on free-text commits. */
export interface ComboboxChangePayload {
value: any;
option: any;
selected: boolean;
text?: string;
}
/** `create` payload — the (untrimmed) query the user asked to create. */
export interface ComboboxCreatePayload {
query: string;
}
/** An entry of the `groups` prop. */
export interface ComboboxGroup {
id: string;
label: string;
}
/** `chip` slot params — `remove()` removes the chip and refocuses the input. */
export interface ComboboxChipSlotCtx {
option: any;
remove: () => void;
index: number;
}
/** `option` slot params. */
export interface ComboboxOptionSlotCtx {
option: any;
index: number;
active: boolean;
selected: boolean;
disabled: boolean;
}
/** `empty` / `create` slot params. */
export interface ComboboxQuerySlotCtx {
query: string;
}
/** `groupHeading` slot params. */
export interface ComboboxGroupHeadingSlotCtx {
group: ComboboxGroup;
}
/** `groupMore` slot params. */
export interface ComboboxGroupMoreSlotCtx {
group: ComboboxGroup | null;
hidden: number;
expand: () => void;
}
</script>
<script lang="ts">
import Popover from '@rozie-ui/popover-svelte';
import { applyListeners, rozieAttr, rozieDisplay, rozieStyle } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
interface Props extends Omit<import('svelte/elements').SvelteHTMLElements['div'], 'value' | 'options' | 'placeholder' | 'disabled' | 'disableFilter' | 'ariaLabel' | 'idBase' | 'inline' | 'closeOnSelect' | 'multiple' | 'creatable' | 'optionLabel' | 'optionValue' | 'optionDisabled' | 'virtual' | 'estimateRowHeight' | 'maxHeight' | 'groups' | 'groupCap' | 'placement' | 'offset' | 'disableFlip' | 'disableShift' | 'block' | 'chipLayout' | 'disableOpenOnFocus' | 'hideEmpty' | 'delimiters' | 'validate' | 'splitPaste' | 'commitOnBlur' | 'selectOnTab' | 'chip' | 'option' | 'empty' | 'create' | 'groupHeading' | 'groupMore' | 'snippets' | 'onsearch' | 'onchange' | 'oncreate' | 'children'> {
/**
* 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 bind:value={country} options={countries} />
*/
value?: (unknown) | null;
/**
* The option list — `[{ value, label, disabled?, group? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, an optional `disabled` flag makes an option non-selectable, and an optional `group` string buckets the option under a matching entry of the `groups` prop (or a first-appearance fallback section) when grouping is active.
*/
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, option and popup elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`, the listbox id is `idBase + "-list"`. Leave it empty (the default) and each instance generates a unique id base after mount (`rozie-combobox-<n>`); set it when you need stable, predictable ids. 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. Unset (default) resolves through `effectiveCloseOnSelect()`: `true` in single-select (today's default behavior) and `false` in `multiple` mode, where closing after every chip pick would make multi-select unusable. Pass an explicit `true` or `false` to override in either mode.
*/
closeOnSelect?: (boolean) | null;
/**
* `value` widens to hold an **array** of selected values and remains the sole `model: true` prop, so the Angular `ControlValueAccessor` is preserved (a second model would forfeit it — `ROZ125`). Re-selecting an already-selected option toggles it off. Default `false` is byte-identical to single-select.
*/
multiple?: boolean;
/**
* When the user commits text matching no option (case-insensitive, trimmed, exact label equality — no Unicode normalization applied), combobox emits `create` with the query and writes NOTHING to `value` — the consumer adds the option to `options` and updates the model itself. Composes with `multiple`. Turning this on replaces the `#empty` fill with the `#create` row whenever the query is creatable (non-empty, no exact match); `#empty` still renders for an empty or whitespace-only query. Default `false` is byte-identical to today.
*/
creatable?: 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;
/**
* Ordered section list `[{ id, label }]` setting group order + heading text. Options are partitioned by their optional `group?` string; groups present on options but absent here fall back to first-appearance order after the listed ones. Empty/absent ⇒ flat, ungrouped rendering (default).
*/
groups?: any[];
/**
* Cap each native section group to its first `groupCap` results, adding a keyboard-reachable '+N more' row that expands that group IN PLACE when activated. `0`/absent = uncapped (default). Only applies to the non-virtual grouped render (`groups` non-empty); ignored when `virtual` is on.
*/
groupCap?: number;
/**
* Floating UI placement of the popup relative to the control, forwarded to the composed `@rozie-ui/popover` leaf — one of `top`/`right`/`bottom`/`left`, each optionally suffixed `-start`/`-end`. Default `"bottom-start"` matches the pre-Phase-86 static popup alignment (flush with the control's left edge). Ignored when `inline` is set.
*/
placement?: string;
/**
* Gap in pixels between the control and the popup, forwarded to the composed `@rozie-ui/popover` leaf. Default `4` preserves the pre-Phase-86 resting gap (`--rozie-combobox-list-gap`). Ignored when `inline` is set.
*/
offset?: number;
/**
* Disable the popup's Floating UI `flip` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup flips above the control when it would overflow the viewport below; set this to keep it pinned to `placement` regardless. Ignored when `inline` is set.
*/
disableFlip?: boolean;
/**
* Disable the popup's Floating UI `shift` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup shifts to stay within the viewport; set this to keep it strictly aligned to the control. Ignored when `inline` is set.
*/
disableShift?: boolean;
/**
* Fill the container: the root becomes `display: block; width: 100%`, the control (chips + input) stretches to that width, and the width-matched popup follows. Adds the `rozie-combobox--block` modifier class on the root. Default `false` keeps the fixed `--rozie-combobox-width` sizing.
*/
block?: boolean;
/**
* Chip rail layout under `multiple`: `'stacked'` (default) renders the chips above the input; `'inline'` puts the chips and the input on ONE wrapping row (the Tags layout), with the input taking the remaining width (`flex: 1`, never narrower than `--rozie-combobox-inline-input-min-width`). Only meaningful with `multiple`.
*/
chipLayout?: string;
/**
* Do not open the list when the input gains focus. Typing and ArrowDown / ArrowUp still open it. Default `false` opens on focus.
*/
disableOpenOnFocus?: boolean;
/**
* Show nothing instead of the empty state: when there are no option rows and no create row, the popup is not shown, the input reports `aria-expanded="false"`, and Escape is left to the host (not `preventDefault`ed). This is the supported way to render no popup at all; filling the `empty` slot with nothing still renders the fallback on most targets.
*/
hideEmpty?: boolean;
/**
* Keys that commit the **typed text** as a value (matched against the key event's `key`), under `multiple` only — a delimiter never picks the highlighted option. Character entries (e.g. `[',', ';']`) also split pasted text: a paste containing a delimiter is split on them, every non-empty trimmed part that `validate` accepts is committed, and the rejected parts are inserted at the caret (replacing the selection) like an ordinary paste, so text typed before the paste is kept. Use `splitPaste` to replace this split. `'Enter'` and `'Tab'` are allowed; Enter then commits the typed text only when no option is highlighted. A non-empty list (or `validate`, `splitPaste` or `commitOnBlur`) turns on free-text commits, so Enter with no highlighted option commits the typed text too. Default `[]` (off).
* @example
* <Combobox multiple bind:value={to} options={contacts} delimiters={delims} />
*/
delimiters?: any[];
/**
* Free-text gate and normaliser, `(text: string) => string | boolean | null | undefined`, under `multiple` only. Called with the trimmed typed (or pasted) text before every free-text commit. Return the **string to store** (e.g. the bare address out of `Sam Roe <sam@x.test>`), `true` to store the text as typed, or a falsy value (`false` / `null` / `''`) to reject it — rejected text stays in the input. The same shape as Tags' `validate`. Setting it also turns on free-text commits (Enter with no highlighted option commits the typed text). A free-text commit appends the stored string to `value` (skipped when already present), clears the input, and emits `change` with `option: null` and the stored string as `text`.
* @example
* <Combobox multiple bind:value={to} options={contacts} validate={toAddress} />
*/
validate?: ((...args: any[]) => any) | null;
/**
* Replaces the built-in paste split, `(text: string) => string[] | null`, under `multiple` only. Called with the clipboard text on every paste. Return the parts to commit — each is trimmed and passed through `validate`; accepted parts are committed and the rejected ones are inserted at the caret — or `null` to leave the paste to the browser untouched. Use it for syntax the delimiter split cannot know about, e.g. a quoted display name containing a comma (`"Roe, Sam" <sam@x.test>`). Setting it also turns on free-text commits.
* @example
* <Combobox multiple bind:value={to} options={contacts} validate={toAddress} splitPaste={splitAddresses} />
*/
splitPaste?: ((...args: any[]) => any) | null;
/**
* Commit the typed text when the input loses focus, under `multiple` only, through `validate` like every other free-text commit: accepted text is committed and the input cleared, rejected text stays. A blur into a pinned host sub-surface (`pinOpen(true)`) does not commit. Setting it also turns on free-text commits. Default `false`.
*/
commitOnBlur?: boolean;
/**
* Tab picks the highlighted option while the popup is visible and an option is highlighted, keeping focus in the input. When nothing is picked, Tab moves focus normally. Default `false` (Tab always moves focus).
*/
selectOnTab?: boolean;
chip?: Snippet<[{ option: any; remove: () => void; index: number }]>;
option?: Snippet<[{ option: any; index: number; active: boolean; selected: boolean; disabled: boolean }]>;
empty?: Snippet<[{ query: string }]>;
create?: Snippet<[{ query: string }]>;
groupHeading?: Snippet<[{ group: ComboboxGroup }]>;
groupMore?: Snippet<[{ group: ComboboxGroup | null; hidden: number; expand: () => void }]>;
snippets?: Record<string, any>;
onsearch?: (payload: ComboboxSearchPayload) => void;
onchange?: (payload: ComboboxChangePayload) => void;
oncreate?: (payload: ComboboxCreatePayload) => void;
}
let __defaultOptions = (() => [])();
let __defaultGroups = (() => [])();
let __defaultDelimiters = (() => [])();
let {
value = $bindable(null),
options = __defaultOptions,
placeholder = '',
disabled = false,
disableFilter = false,
ariaLabel = null,
idBase = '',
inline = false,
closeOnSelect = null,
multiple = false,
creatable = false,
optionLabel = null,
optionValue = null,
optionDisabled = null,
virtual = false,
estimateRowHeight = 36,
maxHeight = '',
groups = __defaultGroups,
groupCap = 0,
placement = 'bottom-start',
offset = 4,
disableFlip = false,
disableShift = false,
block = false,
chipLayout = 'stacked',
disableOpenOnFocus = false,
hideEmpty = false,
delimiters = __defaultDelimiters,
validate = null,
splitPaste = null,
commitOnBlur = false,
selectOnTab = false,
chip: __chipProp,
option: __optionProp,
empty: __emptyProp,
create: __createProp,
groupHeading: __groupHeadingProp,
groupMore: __groupMoreProp,
snippets,
onsearch,
onchange,
oncreate,
...__rozieAttrs
}: Props = $props();
const chip = $derived(__chipProp ?? snippets?.chip);
const option = $derived(__optionProp ?? snippets?.option);
const empty = $derived(__emptyProp ?? snippets?.empty);
const create = $derived(__createProp ?? snippets?.create);
const groupHeading = $derived(__groupHeadingProp ?? snippets?.groupHeading);
const groupMore = $derived(__groupMoreProp ?? snippets?.groupMore);
let inputText = $state('');
let isOpen = $state(false);
let activeIndex = $state(-1);
let rows: any[] = $state([]);
let windowVer = $state(0);
let editVer = $state(0);
let expandedGroups = $state({});
let createdQuery: any = $state(null);
let pinned = $state(false);
let autoId = $state('');
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`).
// - `idRoot()` — the host's id base (Listbox: the `id` prop, else the per-instance id it
// generates in $onMount); `optionId` below derives every option id from it.
// - `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.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;
};
// `idRoot()` is a HOST function (the host's id base: its id prop, else a generated
// ══ 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.
// - rowsWindowed(): boolean — is the ROW axis windowed. REQUIRED, no default — replaces every bare
// truthiness read of the host's windowing prop (D-05); `windowedRows()` /
// `padTop()` / `padBottom()` / `rowIsOutsideWindow()` below call it by
// convention exactly as they already call `pinnedEditIndex()`.
// - colsWindowed(): boolean — is the COLUMN axis windowed. REQUIRED, no default. `false` for every
// host until it defines the real column-axis mechanism (87-04+).
// - columnCount(): number — the leaf-column count the column virtualizer windows over. REQUIRED,
// no default.
// - columnSize(i: number): number — the authoritative width of absolute leaf column `i`, sourced
// from table-core's `getSize()` under D-06. REQUIRED, no default.
// - forcedColumns(): number[] — the D-10 OPTIONAL column-axis mirror of `pinnedEditIndex()`: the
// DataTable host unions pinned + active-cell + editing column indices into
// the column-window slice; listbox/combobox pass an empty array (host-
// provided, defaulting to `[]`).
// - colVirtualizer — the host's SECOND virtual-core instance, windowing the COLUMN axis
// (see the AXIS MECHANISM note below). Host-provided, defaulting to `null`.
// - autoMeasureOn(): boolean — the D-18 REQUIRED content-driven-estimate gate (Phase 87 87-07):
// data-table's real body reads `$props.autoMeasure === true`; listbox/
// combobox/command-palette return `false` so the accumulator branch
// estimateRowSize() gates on is dead code for them (D-20).
// - afterRowRemeasure — OPTIONAL host-owned mutable `let` (defaults to a no-op / undefined),
// assigned to refineRowEstimate() (below) by the host. The DataTable
// host's remeasureWindow() (virtualization.rzts) calls it AFTER its
// measureElement sweep so the fold + hysteresis re-feed run on every
// window commit. Routed through a mutable `let` rather than a direct
// call FROM virtualization.rzts INTO this file: a relative-partial CONST
// calling a bare-specifier-partial CONST is the exact forward-reference
// TDZ class remeasureColumnWindow()'s own DataTable.rozie comment
// documents for columnVirtualizerOptions() (inlineScriptPartials()
// groups the relative partial BEFORE the bare-specifier partial in the
// merged per-target output, regardless of source import order). A
// mutable `let` hoists to `useRef` on React and is excluded from a
// useCallback's dependency array, sidestepping the hazard entirely — the
// SAME mechanism `refreshRowModel` already relies on.
//
// AXIS MECHANISM (OQ1 / Assumption A1 — resolved from the installed source in 87-02;
// LANDED in 87-04: `columnVirtualizerOptions()` below IS the second, horizontal instance this
// note originally only documented). `horizontal` is a PER-INSTANCE field of `VirtualizerOptions`
// (`node_modules/@tanstack/virtual-core/dist/esm/index.d.ts:67`, installed version 3.17.1 per
// `package.json`), and every axis-sensitive internal read consults `instance.options.horizontal` —
// `measureElement`'s inlineSize/blockSize + offsetWidth/offsetHeight branch
// (`dist/esm/index.js:137,150`), `observeElementOffset`'s scrollLeft/scrollTop branch
// (`dist/esm/index.js:118-121`), `getMaxScrollOffset`'s scrollWidth/scrollHeight branch
// (`dist/esm/index.js:907-915`), and `scrollWithAdjustments`'s left/top branch
// (`dist/esm/index.js:152-161`). So ONE `Virtualizer` instance windows exactly ONE axis: the column
// axis needs its own SECOND, independent `Virtualizer` instance constructed with `horizontal: true`,
// sharing the SAME `getScrollElement()` (the `rdt-scroll` wrapper) the row instance already uses.
// Two options the row axis does not set that the column instance will need: `isRtl?: boolean`
// (data-table ships an RTL grid path) and `overscan?: number` (D-07 gives the column axis its own
// hardcoded constant, separate from the row axis's `overscan: 8` below).
//
// isRtl WIRING (gap-closure 87-09, LANDED — see `ensureColRtlWatch()`/`isColRtl()` below,
// immediately ahead of `columnVirtualizerOptions()`): data-table has no construction-time RTL
// signal (no `dir`/`rtl` prop), and `dir` can be set on `gridScrollEl` at ANY point relative to
// mount. `isRtl` is therefore computed LIVE via `getComputedStyle`, not baked in once.
// 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;
};
// COL_OVERSCAN (D-07): the column axis's own hardcoded overscan constant, separate from the
// row axis's `overscan: 8` below. Columns are far wider than rows are tall, so one number
// cannot serve both axes; no prop is exposed because no consumer has asked to tune the row
// overscan across the four phases it has shipped. Unused until 87-04 constructs the second,
// horizontal Virtualizer instance (see the AXIS MECHANISM note above).
// ══ Phase 87 87-07 (D-15/D-18) — content-driven auto-measure: the shared engine's FIRST
// mutable top-level state. Hoisted to `useRef` PER-INSTANCE by the React emitter's
// hoistModuleLet — the SAME mechanism already load-bearing for `table`, `virtualizer`,
// `remeasurePending`, and `gridScrollEl` in the DataTable host (Task 1's confirmed
// precedent), so two DataTable instances on one page never share an accumulator
// (T-87-07-04). measuredRowTotal/measuredRowCount together give the running MEAN of every
// row folded in so far; lastFedRowEstimate is the estimate value most recently pushed into
// virtual-core (the hysteresis comparison baseline). ══
let measuredRowTotal = 0;
let measuredRowCount = 0;
// ══ Gap-closure 87-10 — windowVerBumpPending / bumpWindowVer(): coalesce EVERY $data.windowVer
// write behind a SINGLE microtask-deferred increment, regardless of how many callers request
// one within the same synchronous JS task. ══
//
// ROOT CAUSE (framework-agnostic; the Solid-specific symptom this closes only EXPOSES it) —
// confirmed by instrumenting the installed @tanstack/virtual-core@3.17.1 source directly
// (dist/esm/index.js), not by reasoning abstractly: virtual-core's resizeItem() calls
// `this.notify(false)` — synchronously invoking `virtualizerOptions().onChange` below — EVERY
// TIME a measured row's real size differs from its cached one (`delta !== 0`), independent of
// framework (dist/esm/index.js:836-874). remeasureWindow()'s CR-01 sweep
// (packages/ui/data-table/src/virtualization.rzts) measures EVERY currently-rendered `<tr>` in
// ONE for-loop BEFORE calling afterRowRemeasure() (refineRowEstimate() below) — so a single
// synchronous JS task (e.g. the very first measurement pass, which transitions N never-before-
// measured rows from the flat seed to their real heights) can fire onChange, and therefore an
// UNCOALESCED `$data.windowVer = $data.windowVer + 1`, MANY times in a row — well BEFORE
// refineRowEstimate()'s own fold-then-re-feed (which runs only AFTER that loop finishes) has
// folded those same measurements into the running mean or re-fed the converged estimate into
// virtual-core via setOptions(). A live trace of this exact sequence (instrumented resizeItem/
// getMeasurements calls, DataTableColumnVirtualDemo, autoMeasure on) showed Vue batching 3
// resizeItem calls before its ONE downstream re-render reads getMeasurements() — already
// reflecting the fully-folded, re-fed state — versus Solid re-running its padTop()/padBottom()
// effects SYNCHRONOUSLY and IMMEDIATELY on EVERY individual windowVer write (11 interleaved
// resize-then-immediate-recompute pairs, each recompute happening mid-sweep, before
// refineRowEstimate() had run even once). React/Vue/Svelte/Angular/Lit all batch their own
// reactivity to at least a microtask boundary, so their downstream reads land AFTER the whole
// synchronous burst (measurement sweep + fold + re-feed) completes — accidentally correct, not
// correct by construction. Solid does not auto-batch a signal write made from outside a
// Solid-owned event/effect context, so it is the one target where the mid-burst TORN read is
// externally observable. Because `setOptions()` + `_willUpdate()` alone do NOT invalidate
// virtual-core's own `getMeasurements()` memo (keyed on itemSizeCacheVersion /
// getMeasurementOptions() — never on the estimateSize FUNCTION reference itself; confirmed from
// the same installed source, dist/esm/index.js:585-587,624), Solid's LAST such mid-sweep
// recompute is also the LAST time getMeasurements() is ever invoked for that sweep once no
// further row happens to differ from its cache — so the DOM stays frozen on that stale,
// pre-fold/pre-re-feed snapshot indefinitely, even though the accumulator itself has already
// converged correctly (T-87-07's own confirmed finding).
//
// FIX: coalesce every requester of a windowVer bump — virtual-core's own onChange AND
// refineRowEstimate()'s explicit re-feed bump — behind ONE microtask-deferred write, the SAME
// idiom scheduleRemeasure() already uses in virtualization.rzts. This makes the render happen
// EXACTLY ONCE, strictly AFTER the entire synchronous burst (including refineRowEstimate()'s
// fold + re-feed) on EVERY target, by construction rather than by incidental host-framework
// batching. Scoped to the ROW axis only: colVirtualizer never calls resizeItem() at all (D-06 —
// column widths come from table-core's getSize() oracle, never measured from the DOM), so
// columnVirtualizerOptions()'s onChange cannot hit this burst class and is left untouched.
let windowVerBumpPending = false;
const bumpWindowVer = (): void => {
if (windowVerBumpPending) return;
windowVerBumpPending = true;
const flush = () => {
windowVerBumpPending = false;
windowVer = windowVer + 1;
};
// Mirrors scheduleRemeasure()'s own defensive queueMicrotask-with-setTimeout-fallback
// (virtualization.rzts) for environments where queueMicrotask is unavailable.
if (typeof queueMicrotask !== 'undefined') queueMicrotask(flush);else setTimeout(flush, 0);
};
// ESTIMATE_REFEED_DELTA_PX (D-15): the hysteresis threshold gating a re-feed into
// virtual-core. Without it, a mean nudging by a fraction of a pixel on every fold would
// re-feed on every window commit — the T-87-07-01 DoS control, paired with virtual-core's
// own measureElement/resizeItem idempotence (see refineRowEstimate() below).
// estimateRowSize(i) (D-15/D-17): the estimateSize() resolver. MUST check !autoMeasureOn()
// FIRST so the off path touches zero accumulator state and returns $props.estimateRowHeight
// verbatim (D-17's byte-behavioral no-op). The zero-measurements case (first paint,
// regardless of autoMeasure) still returns the seed — the very first render has nothing
// measured yet either way (D-15).
const estimateRowSize = (i: number): number => {
if (!autoMeasureOn()) return estimateRowHeight;
if (measuredRowCount === 0) return estimateRowHeight;
return Math.round(measuredRowTotal / measuredRowCount);
};
// foldMeasuredRow(index, height): fold ONE measured row's height into the running-mean
// accumulator, UPDATING (not double-adding) an already-folded index (T-87-07-03).
// 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's windowVer write is routed through
// bumpWindowVer() (87-10) rather than a raw `$data.x = $data.x + 1` — resizeItem() can call
// this onChange MANY times in a single synchronous sweep (once per row whose real measured
// size differs from its cache, e.g. every never-before-measured row in the FIRST window),
// and coalescing those into one microtask-deferred write is what keeps every target's render
// landing strictly AFTER the whole sweep (see bumpWindowVer()'s own comment for the confirmed
// Solid-specific rendering gap this closes). The React emitter still lowers the underlying
// `$data.windowVer = $data.windowVer + 1` to functional setState — correct even deferred to a
// microtask, exactly as it was correct from a mount closure before.
const virtualizerOptions = (): any => ({
count: windowSource().length,
getScrollElement: () => gridScrollEl,
estimateSize: (i: any) => estimateRowSize(i),
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
bumpWindowVer();
// 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.
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).
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) {
// Rows OFF (Phase 87 D-04: this now includes the colsWindowed()-only path, since the
// wrapper template is entered whenever isWindowed(), not just rowsWindowed() — the row
// virtualizer is never constructed when only the column axis is windowed, D-04) → the FULL
// set, with a SYNTHETIC `vi.index` set to each row's array position (matching rowIndexOf's
// own `$data.rows.indexOf(row)` semantics exactly, since $data.rows IS windowSource()'s
// output here). Every windowed body binding reads wr.vi.index (data-row, aria-rowindex,
// colIndexOf, isEditing, the fill handle) — a bare `null` there is a hard crash the moment
// this branch is reached with the wrapper mounted, which colsWindowed()-only now does.
// Row-virtual ON but the virtualizer is not yet constructed (pre-$onMount first paint) →
// render NOTHING so the template never dereferences a not-yet-real `vi`; the rows appear on
// the first onChange after _didMount.
if (!rowsWindowed()) {
const rowList = rows || [];
return rowList.map((r: any, i: any) => ({
vi: {
index: i
},
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.
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 (!rowsWindowed() || !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 (!rowsWindowed() || !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?
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).
const rowIsOutsideWindow = (r: any) => {
if (!rowsWindowed() || !virtualizer) return false;
const items = virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
// ══ Phase 87 87-04 — the column-axis analogs of windowedRows()/padTop()/padBottom()/
// rowIsOutsideWindow() above. The column axis has no "row-shaped" identity to carry alongside
// a VirtualItem (a column is not a full-model object the way a row is), so windowedColIndices()
// returns bare ABSOLUTE leaf-column indices; the template resolves each index back to a header/
// cell through the host's own header-group / visibleCellsFor lookups (D-08/D-09). ══
// windowedColIndices(): the ordered array of ABSOLUTE leaf-column indices to render.
// 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';
// ---- native option grouping (combobox-native-groups: src/internal/groupOptions.ts) ----
// The PURE stable-partition helper is a RUNTIME import (unlike listCore/windowing
// above, it is NOT a compile-time `.rzts` partial that dissolves at compile) —
// codegen's `copyInternal` vendors it verbatim into each leaf at
// `./internal/groupOptions`, mirroring command-palette's `scoreCommands.ts`.
import { groupOptions } from './internal/groupOptions';
// 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;
// Scroll-end pin state (see recordScrollEnd()): whether the USER left the view at the end,
// the option count at that moment, and the last scrollTop already accounted for.
let scrollEndPinned: boolean = false;
let scrollEndPinnedCount: number = -1;
let scrollEndPinnedTop: number = -1;
// Non-reactive per-instance flag (Phase 86 R2, plan 86-03, Solid-only): true for
// the duration of an onFocus-triggered open transition (set before the isOpen
// write, cleared in the deferred microtask after). Lets onBlur distinguish a
// blur caused by Solid recreating the anchor's DOM mid-open (skip closing) from
// a genuine user-initiated blur (close normally). See onFocus/onBlur below.
let openingInProgress = false;
// Non-reactive per-instance flag (combobox-virtual-reactivity phase): set true once
// $onMount has run; read by windowedView() below so the blank-frame fallback (D-4) only
// fires on a genuine RUNTIME flip — a virtual:true-at-mount (never-flipped) consumer's
// first paint stays byte-stable (windowedRows()'s own pre-mount `[]` still applies before
// didMount flips true). Mirrors the same write-in-$onMount/read-elsewhere holder class.
let didMount = 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.
//
// Quick 260717-8zb dogfood: re-expressed on the `$memo(fn, keyFn)` primitive.
// `$memo` lowers (core, shared across all 6 targets) to a member-mutated
// fresh-object cache const + a wrapper function — EXACTLY this foCache shape,
// generalized. On React the emitted cache const is stabilized to
// `useMemo(() => ({…}), [])` by the EXISTING collectMutatedInstanceBinders/
// tryWrapMutatedInstanceUseMemo machinery (feedback_react_const_mutinstance_
// not_stabilized) — no per-target $memo code. On the 5 setup-once targets the
// top-level consts persist for the instance lifetime naturally.
//
// keyFn is the SUBSCRIBE-FIRST half (fine-grained Solid <For> / Svelte
// {#each}): it reads ALL FOUR reactive inputs UNCONDITIONALLY — $data.inputText
// even when disableFilter is true (mirrors windowing.rzts windowedRows
// void-touch discipline) and $props.groups even when $props.virtual (so a
// groups change while windowed still invalidates the cache once virtual
// toggles off) — evaluated BEFORE $memo's cache-hit check, so the r-for
// accessor subscribes to them on every eval. 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 / groups-ref) change.
//
// fn is the MISS path (unchanged from the hand-rolled foCache): run the
// filter, then (native option grouping, combobox-native-groups) a
// NON-VIRTUAL-ONLY stable re-partition into group-visual order, then map to
// wrapper rows.
const filteredOptionsCache = {
keys: null as any[] | null,
val: null as any
};
const filteredOptions = () => {
const __rozieMemoKey = (() => {
const opts = Array.isArray(options) ? options : [];
const df = !!disableFilter;
const q = String(inputText == null ? '' : inputText);
const groupsProp = groups;
return [opts, q, df, groupsProp];
})();
const __rozieMemoPrev = filteredOptionsCache.keys;
if (__rozieMemoPrev !== null && __rozieMemoPrev.length === __rozieMemoKey.length && __rozieMemoKey.every((v: any, i: any) => v === __rozieMemoPrev[i])) {
return filteredOptionsCache.val;
}
const __rozieMemoVal = (() => {
const opts = Array.isArray(options) ? options : [];
const df = !!disableFilter;
const q = String(inputText == null ? '' : inputText);
const groupsProp = groups;
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
// Gated to !$props.virtual (groups×virtual is deferred/unsupported per design) AND to
// $props.groups being a NON-EMPTY array — an explicit author opt-in. This is deliberately
// NOT just "!$props.virtual" (groupOptions() would otherwise also fire whenever any raw
// option happens to carry a `.group` field, even with `groups` absent — a real collision
// discovered against command-palette's CommandItem.group, which is a PRE-EXISTING,
// unrelated per-row-badge field, not an opt-in to combobox's native grouping. The design's
// "Empty/absent `groups` ⇒ today's flat behavior, byte-identical" contract is about the
// `groups` PROP only — never inferred from incidental option shape.
if (!virtual && Array.isArray(groupsProp) && groupsProp.length > 0) {
const partition = groupOptions(list, groupsProp, (o: any) => o && o.group != null ? String(o.group) : null);
list = partition.ordered;
}
// `_i` is assigned over the (now group-ordered) list, so the flat keyboard model
// (activeIndex/aria-activedescendant/nextEnabled) walks visual order unchanged.
// `group` carries the wrapper's normalized group id for groupBlocks() below.
return list.map((o: any, i: any) => ({
value: valueOf(o),
label: labelOf(o),
disabled: disabledOf(o),
_i: i,
id: valueOf(o),
option: o,
group: o && o.group != null ? String(o.group) : null
}));
})();
filteredOptionsCache.keys = __rozieMemoKey;
filteredOptionsCache.val = __rozieMemoVal;
return __rozieMemoVal;
};
// 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();
// windowedView() (combobox-virtual-reactivity, VIRT-FALLBACK): the combobox-side
// blank-frame fallback for the mid-flip frame. While `virtual` is on but the virtualizer
// has not yet (re)attached (didMount-gated, so the never-flipped virtual:true-at-mount
// first paint is untouched — windowedRows()'s own pre-mount `[]` still governs it),
// render the UN-WINDOWED full windowSource() slice mapped to the `{ vi: { index }, row }`
// shape the windowed template consumes (`wr.vi.index` resolves to the wrapper's own `_i`,
// since windowSource() IS the filtered/indexed list navRows()/activeIndex already walk).
// Once the virtualizer is built, delegates to windowedRows() UNCHANGED — byte-identical
// to today's steady windowed state. Entirely combobox-side: @rozie-ui/headless-core/
// windowing.rzts is untouched, preserving data-table's B13 A==B byte-identity + its
// empty-diff regen.
const windowedView = () => {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}) — touch windowVer at the
// TOP, mirroring windowedRows()'s own subscribe-first discipline (windowing.rzts), so
// the accessor re-runs when buildVirtualizer()/kickWindow() bump windowVer once the
// virtualizer attaches — the transition OUT of this fallback and into windowedRows().
void windowVer;
if (virtual && !virtualizer && didMount) {
return windowSource().map((row: any) => ({
vi: {
index: row._i
},
row
}));
}
return windowedRows();
};
// ---- native option grouping render helpers (combobox-native-groups) ---------------
// groupBlocks(): re-partition the ALREADY group-ordered filteredOptions() wrappers into
// CONTIGUOUS runs by wrapper.group (trivial + guarantees `_i` alignment, since `ordered`
// from groupOptions() is already group-contiguous). Attaches each run's `{ id, label }`
// from $props.groups (fallback label = the group id itself). Plain function — never
// $computed (mirrors filteredOptions()'s convention). Non-virtual only (isGrouped() below
// already gates the template branch that calls this).
const groupBlocks = () => {
const wrappers = filteredOptions();
const groupsProp = Array.isArray(groups) ? groups : [];
const labelFor = (gid: any) => {
const found = groupsProp.find((g: any) => g && g.id === gid);
return found ? found.label : gid;
};
const blocks = [];
let lastGid;
for (let i = 0; i < wrappers.length; i++) {
const w = wrappers[i];
if (i === 0 || w.group !== lastGid) {
blocks.push({
group: w.group == null ? null : {
id: w.group,
label: labelFor(w.group)
},
items: [w]
});
} else {
blocks[blocks.length - 1].items.push(w);
}
lastGid = w.group;
}
return blocks;
};
// isGrouped(): the grouped-vs-flat template branch selector. Grouping is active
// (non-virtual only) SOLELY when the author explicitly set a non-empty `groups` prop —
// deliberately NOT "OR any option carries a group" (a real collision discovered against
// command-palette's pre-existing CommandItem.group per-row-badge field; see the
// filteredOptions() comment above). Mirrors that same non-empty-`groups` gate exactly, so
// isGrouped() and the filteredOptions() partition never disagree about which branch is active.
const isGrouped = () => !virtual && Array.isArray(groups) && groups.length > 0;
// ---- per-group result cap + expand-in-place "+N more" (combobox-group-cap) --------
// capNum(): coerce $props.groupCap to a whole, positive cap; anything else (NaN,
// negative, absent) degrades to 0 (uncapped). Plain function — never $computed.
const capNum = () => {
const n = Number(groupCap);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
};
// isCapped(): the capped-render branch selector. isGrouped() already gates non-
// virtual + non-empty `groups`, so the cap is automatically gated OUT of the
// virtual and ungrouped paths.
const isCapped = () => isGrouped() && capNum() > 0;
// gkey(gid): normalize a group id (possibly null, for the leading ungrouped
// section) into an expandedGroups map key.
const gkey = (gid: any) => gid == null ? '__ungrouped__' : String(gid);
// isExpanded(gid): whether the group has been expanded via its "+N more" row.
const isExpanded = (gid: any) => !!(expandedGroups && expandedGroups[gkey(gid)]);
// expandGroup(gid): replace $data.expandedGroups IMMUTABLY (load-bearing for
// React re-render — feedback_react_const_mutinstance_not_stabilized / the
// graph-writeback immutability rule).
const expandGroup = (gid: any) => {
expandedGroups = Object.assign({}, expandedGroups, {
[gkey(gid)]: true
});
};
// cappedBlocks(): the visible-block model for the capped render — groupBlocks()
// re-sliced to `capNum()` per group (unless expanded or non-overflowing), with a
// trailing "+N more" row appended to any still-capped block. Re-indexes `_i` as a
// running counter over the WHOLE visible+more sequence so option ids/aria-
// activedescendant stay contiguous and never disagree with navRows() below.
const cappedBlocks = () => {
const blocks = groupBlocks();
const cap = capNum();
let running = 0;
const out = [];
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
const gid = blk.group ? blk.group.id : null;
const showAll = isExpanded(gid) || blk.items.length <= cap;
const visibleSrc = showAll ? blk.items : blk.items.slice(0, cap);
const items = [];
for (let vi = 0; vi < visibleSrc.length; vi++) {
items.push(Object.assign({}, visibleSrc[vi], {
_i: running
}));
running++;
}
let more: any = null;
if (!showAll) {
more = {
isMore: true,
group: gid,
hidden: blk.items.length - cap,
disabled: false,
_i: running,
expand: () => expandGroup(gid)
};
running++;
}
out.push({
group: blk.group,
items,
more
});
}
return out;
};
// ---- creatable mode (Phase 86 R3, D-17..D-20) ---------------------------
// normalizedQuery(): trimmed + lower-cased query — reuses the SAME case-fold
// filteredOptions() already applies above, but for an EXACT-EQUALITY
// comparison, never a substring search, and with NO Unicode normalization
// (R3 locked: a composition-form difference must NOT be treated as a match).
const normalizedQuery = () => String(inputText == null ? '' : inputText).trim().toLowerCase();
// queryMatchesOption(nq): whether the (already-normalized) query is an exact,
// case-insensitive, trimmed match of some option's label.
const queryMatchesOption = (nq: any) => {
const opts = Array.isArray(options) ? options : [];
return opts.some((o: any) => String(labelOf(o)).trim().toLowerCase() === nq);
};
// isCreatableQuery(): the create-row visibility gate (also gates the `#empty`
// -> `#create` swap, D-19). `creatable` must be set, the normalized query
// must be non-empty (an empty/whitespace-only query never offers create —
// `#empty` keeps its job there), and no option's normalized label may equal
// it exactly.
const isCreatableQuery = () => {
if (!creatable) return false;
const nq = normalizedQuery();
if (!nq) return false;
return !queryMatchesOption(nq);
};
// createRowAt(baseCount): the synthetic, non-option `role="option"` create
// row (D-17) — mirrors the `groupMore` "+N more" row shape exactly (a real
// id, arrow-reachable, commits through the SAME selectOption() dispatch
// without writing the model). Each render branch passes ITS OWN flattened
// pre-create-row row count (`baseCount`) as the running index, exactly as
// `cappedBlocks()` already re-indexes `_i` across options + the more row —
// so ids / aria-activedescendant / navRows() can never disagree.
const createRowAt = (baseCount: any) => ({
isCreate: true,
_i: baseCount,
disabled: false
});
// cappedRowCount(): the total navigable row count cappedBlocks() flattens to
// (visible items + more-rows, across every block) — the running index the
// capped branch's own create row (below) must continue from. Mirrors
// cappedBlocks()'s own `running` counter without re-deriving `_i` per item.
const cappedRowCount = () => {
const blocks = cappedBlocks();
let n = 0;
for (let bi = 0; bi < blocks.length; bi++) {
n += blocks[bi].items.length;
if (blocks[bi].more) n++;
}
return n;
};
// navRows(): the SINGLE keyboard/aria source of truth. Returns the EXACT
// filteredOptions() reference when not capped and not creatable (byte-
// identical-off — untouched virtual/ungrouped keyboard path); flattens
// cappedBlocks() into visible items + more-rows, in order, when capped.
// Appends the create row, AFTER the full flattened visible(+more) sequence,
// whenever isCreatableQuery() — R3's locked "renders last, after all options
// and group sections" is a positional fact here, not a per-branch special case.
const navRows = () => {
if (!isCapped()) {
const base = filteredOptions();
if (!isCreatableQuery()) return base;
return base.concat([createRowAt(base.length)]);
}
const out = [];
const blocks = cappedBlocks();
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
for (let ii = 0; ii < blk.items.length; ii++) out.push(blk.items[ii]);
if (blk.more) out.push(blk.more);
}
if (isCreatableQuery()) out.push(createRowAt(out.length));
return out;
};
// 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;
// D-05 windowing.rzts host-contract one-liner (Phase 87 87-02). rowsWindowed() preserves
// today's EXACT truthiness (byte-behavior-identical) — it is the REQUIRED symbol
// windowing.rzts calls in place of a bare `$props.virtual` read.
//
// GAP-CLOSURE 87-16 (WR-02): the column-axis host-contract symbols (`colVirtualizer`,
// `colsWindowed()`, `columnCount()`, `columnSize()`, `forcedColumns()`) that 87-02 added
// alongside this were REMOVED here — they were dead code shipped on a mistaken premise
// about the compiler's tree-shaking BFS. Combobox imports only `{ virtualItemKey,
// virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow }`
// from windowing.rzts; none of those functions' bodies reference the column-axis symbols
// (only `columnVirtualizerOptions()`/`windowedColIndices()`/`colPadLeft()`/`colPadRight()`/
// `colIsOutsideWindow()` do, and Combobox never imports any of those), so
// `inlineScriptPartials()`'s BFS never needed them to exist. See 87-REVIEW.md WR-02 /
// 87-16-SUMMARY.md for the verification trail.
const rowsWindowed = () => !!virtual;
// autoMeasureOn() (Phase 87 87-07, D-18/D-20): the content-driven-estimate host-contract
// gate. Combobox never lights this branch — a permanent `false` keeps windowing.rzts's
// estimateRowSize()/refineRowEstimate() accumulator dead code here. RETAINED (unlike the
// column-axis symbols above): `virtualizerOptions()` — which Combobox DOES import and call
// — wires `estimateSize: (i) => estimateRowSize(i)`, and `estimateRowSize()` calls
// `autoMeasureOn()` as its first line. This one IS reachable through the import graph.
const autoMeasureOn = (): boolean => false;
// Keep $data.rows === windowSource() so the windowing math indexes the live filtered set.
const syncRows = () => {
rows = windowSource();
};
// SCROLL-END PIN (the data-table D-19 twin, shared shape with Listbox): keep a user who
// scrolled to the END of a variable-height list at the end while the options in view measure
// taller than their estimate. The view is judged on the DOM, and only at a move the USER
// made — a move is virtual-core's own when it still holds an unreconciled scroll adjustment
// (scrollAdjustments !== 0): its above-viewport compensation writes an ABSOLUTE scrollTop
// computed from its last-observed (stale) offset, so it pulls the view back up from the end
// and must neither clear the pin nor be mistaken for the user leaving the end. That position
// is remembered so the scroll event that later reports it is not read as a user move either.
// (Judging on virtual-core's MODEL, as the data-table host does, fails here: its total grows
// with every option measured in the ResizeObserver batch while its offset stays at the stale
// value, so the pin was cleared mid-batch — every target ended 10-126px short, measured.)
const recordScrollEnd = () => {
if (!virtualizer || !gridScrollEl || virtualizer.scrollState) return;
const top: number = gridScrollEl.scrollTop;
if (top === scrollEndPinnedTop) return;
scrollEndPinnedTop = top;
if (virtualizer.scrollAdjustments !== 0) return;
// Only a list that actually overflows has an end to hold: while the window has not painted
// yet (or the list is closed), scrollHeight <= clientHeight reads as "at the end" and a pin
// recorded then would jump the freshly opened list to the bottom.
const sh = gridScrollEl.scrollHeight;
const ch = gridScrollEl.clientHeight;
scrollEndPinned = ch > 0 && sh - ch > 1 && sh - top - ch <= 1;
scrollEndPinnedCount = windowSource().length;
};
// Re-apply the pin after the framework has committed the window (called from the rAF pass):
// the real maximum is known only then. Not while a programmatic scroll (scrollToIndex) is in
// flight, and not when the option count changed since the user reached the end (a new query
// or appended options must not be auto-followed).
const keepScrollEnd = () => {
if (!scrollEndPinned || !virtualizer || !gridScrollEl || virtualizer.scrollState) return;
if (windowSource().length !== scrollEndPinnedCount) return;
const maxTop: number = gridScrollEl.scrollHeight - gridScrollEl.clientHeight;
if (maxTop - gridScrollEl.scrollTop > 1) {
gridScrollEl.scrollTop = maxTop;
scrollEndPinnedTop = gridScrollEl.scrollTop;
}
};
// 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 = () => {
recordScrollEnd();
if (remeasurePending) return;
remeasurePending = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
// N-05 (quick 260923-rrr): key the rAF pass on the OUTCOME. React and Angular commit the
// recycled window AFTER the first rAF, so one pass measured the OLD options and the new ones
// waited for virtual-core's 150ms scrolling-ended tick — with variable-height options the late
// above-viewport adjustment then moved the whole list (measured). Re-run next frame until the
// committed options cover the virtualizer's window, bounded (the data-table host twin).
let rafAttempts = 0;
const rafPass = () => {
const covered = remeasureWindow();
rafAttempts = rafAttempts + 1;
if (!covered && rafAttempts < 10 && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(rafPass);
return;
}
keepScrollEnd();
remeasurePending = false;
};
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 true;
if (virtualizer.scrollState) return true;
const els = gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]');
const rendered = new Set();
for (const el of els as any) {
virtualizer.measureElement(el);
rendered.add(el.getAttribute('data-index'));
}
// N-05: false while the framework has not yet committed the recycled window.
const items = virtualizer.getVirtualItems();
for (let i = 0; i < items.length; i++) {
if (!rendered.has(String(items[i].index))) return false;
}
return true;
};
// Keep the active option visible inside the popup. When windowing, route through the
// virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window scrolls
// into view (the windowed-arrow-nav seam). When NOT windowing, resolve the active
// option element directly (a within-own-shadow query, Lit-safe) and scrollIntoView it
// with 'nearest' block alignment — a plain long list taller than the popup's
// max-height must also keep the active option visible during arrow navigation.
const scrollActiveIntoView = () => {
if (!virtual && isOpen && activeIndex >= 0) {
const list = __rozieRoot ? __rozieRoot!.querySelector('.rozie-combobox-list') : null;
const opt = list ? list.querySelector('#' + optId(activeIndex)) : null;
if (opt) opt.scrollIntoView({
block: 'nearest'
});
return;
}
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();
};
// idRoot(): the id base — the `idBase` prop, else the per-instance id generated
// in $onMount (`autoId`), else the pre-mount fallback. Generated after mount (not
// during setup) so a server render and the hydrating client agree.
const idRoot = () => idBase || autoId || 'rozie-combobox';
const optId = (i: any) => idRoot() + '-opt-' + i;
const listId = () => idRoot() + '-list';
// popupVisible() (hideEmpty, COMBOBOX-SPEC item 4): whether the popup is actually
// SHOWN — open AND (unless `hideEmpty`) something to render. With `hideEmpty` an
// open popup with no option rows AND no create row counts as hidden: the list
// branches do not render, aria-expanded reports false, and Escape is left to the
// host (B4). Without `hideEmpty` this is exactly `$data.isOpen` (byte-identical-off).
const popupVisible = () => {
if (!isOpen) return false;
if (!hideEmpty) return true;
return navRows().length > 0;
};
// The active option's id for aria-activedescendant (null when none).
const activeId = () => {
const list = navRows();
if (popupVisible() && activeIndex >= 0 && list[activeIndex]) return optId(activeIndex);
return null;
};
// activeOption() (handle verb, COMBOBOX-SPEC item 8): the highlighted RAW source
// option, or null (nothing highlighted, the popup is hidden, or the highlighted
// row is a synthetic "+N more" / create row).
export const activeOption: () => any = () => {
const list = navRows();
const ai = activeIndex;
if (!popupVisible() || ai < 0) return null;
const row = list[ai];
if (!row || row.isMore || row.isCreate) return null;
return row.option === undefined ? null : row.option;
};
// 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;
};
// ---- multi-select membership + effective-default helpers (Phase 86 R1) -----
// Ported from @rozie-ui/headless-core/listCore.rzts's select()/isSelected()
// algorithm (also shipped, verbatim, via @rozie-ui/listbox) — PORTED, not
// imported: combobox's own open/active/query state machine is deliberately
// host-local (see the header comment above), and listCore.rzts is also
// consumed by the release-ignored listbox family, so pulling this into the
// shared partial would put listbox's frozen leaves back in scope.
//
// selectedValues(): the current selection as a de-duplicated array, tolerant
// of a null/undefined model. De-duplicates the MODEL array itself (not just
// `options`) so a re-normalized selection never reports the same value twice
// even if the model ever ends up holding a duplicate.
const selectedValues = () => {
const cur = value;
const arr = Array.isArray(cur) ? cur : [];
return Array.from(new Set(arr));
};
// isRowSelected(row): array membership under `multiple`, strict equality
// otherwise. Replaces every raw `opt.value === $props.value` / `wr.row.value
// === $props.value` template comparison (task 2) so all four render branches
// share exactly ONE membership check and can never disagree.
const isRowSelected = (row: any) => {
if (!row) return false;
if (multiple) return selectedValues().indexOf(row.value) !== -1;
return row.value === value;
};
// effectiveCloseOnSelect(): resolves the `closeOnSelect` sentinel (see the
// prop's own doc comment above for why the prop's default is `null`, not a
// literal `true`). Unset ⇒ `true` in single-select (today's default,
// unchanged), `false` under `multiple`; an explicit `true`/`false` from the
// consumer always wins in either mode. Every existing `closeOnSelect` read
// routes through this helper so the four render branches cannot disagree.
const effectiveCloseOnSelect = () => {
const v = closeOnSelect;
if (v === true || v === false) return v;
return !multiple;
};
// chipsInline() (chipLayout, COMBOBOX-SPEC item 2): chips + input on one
// wrapping row — only meaningful under `multiple`.
const chipsInline = () => !!multiple && chipLayout === 'inline';
// ---- chip rail (Phase 86 R1, plan 86-05, D-13/D-16/D-18) ---------------
// chipRows(): selectedValues() (already de-duplicated — see above) mapped to
// chip-rail display rows. Each row carries the raw source `option` when it is
// still present in `options` (mirroring how filteredOptions() attaches the raw
// option to every wrapper row), or a raw-value fallback label when the option
// has disappeared from an asynchronously swapped `options` array — the locked
// R1 concurrency edge: an orphan chip persists, labelled by its raw value,
// rather than vanishing. `value` array order IS chip display order (R1
// locked); selectedValues() already preserves it.
const chipRows = () => {
const opts = Array.isArray(options) ? options : [];
return selectedValues().map((v: any) => {
const found = opts.find((o: any) => valueOf(o) === v);
return found ? {
value: v,
label: labelOf(found),
option: found
} : {
value: v,
label: String(v),
option: null
};
});
};
// chipRemoveLabel(row): the aria-label naming what a chip's remove control removes.
const chipRemoveLabel = (row: any) => 'Remove ' + String(row.label);
// removeChipValue(v) is defined AFTER selectOption() below (not here) — React's
// emitter derives each `useCallback`'s static dependency array from the
// helpers its body calls, and `removeChipValue` calls `selectOption`. Declaring
// it before `selectOption`'s own `const` would put `selectOption` in
// `removeChipValue`'s deps array ahead of its OWN initializer in the SAME
// module scope — a real same-render TDZ (`ReferenceError` at runtime on
// React, TS2448 "used before its declaration" at typecheck). Source order
// here IS emission order for these plain top-level consts, so
// `removeChipValue` must textually follow `selectOption`.
// ---- 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`). `effectiveCloseOnSelect()` gates the popup close.
const selectOption = (opt: any) => {
if (!opt) return;
if (opt.isMore) {
expandGroup(opt.group);
activeIndex = opt._i;
return;
}
if (opt.isCreate) {
// Read locals before any write (ROZ138 idiom).
const q = inputText;
const nq = normalizedQuery();
// The double-commit latch (D-17/D-20): a second commit of the SAME
// normalized query — whether a rapid double gesture, or the async
// round-trip window before the consumer's `options` update lands — is a
// no-op. An empty/whitespace normalized query never emits either (the
// row should not even be reachable then, since isCreatableQuery() gates
// it, but this guard is cheap insurance against a stale reference).
if (!nq || nq === createdQuery) return;
createdQuery = nq;
oncreate?.({
query: q
});
// D-20: after `create` fires, local UI state behaves like a pick — the
// effective close-on-select applies, and the query clears in `multiple`
// mode (ready for the next entry) and is left alone in single mode (the
// consumer's async add flows back through the ordinary `value` watch).
// `value` itself is untouched — R3 locked.
if (effectiveCloseOnSelect()) isOpen = false;
if (multiple) clearQuery(null);
activeIndex = -1;
return;
}
if (opt.disabled) return;
if (multiple) {
// Capture whether the value was already present BEFORE the toggle — this
// local is what feeds the `selected` field on the `change` payload (D-15).
const cur = selectedValues();
const wasSelected = cur.indexOf(opt.value) !== -1;
// Fresh array on every commit — in-place mutation (.push/.splice) is
// silently dropped by the React/Solid/Lit/Angular change detectors.
const next = wasSelected ? cur.filter((v: any) => v !== opt.value) : [...cur, opt.value];
value = next;
// D-14: clear the query on pick under `multiple` (not the option's label)
// so Backspace-removes-last stays reachable immediately after a pick.
// `opt.isRemoval` (set only by removeChipValue() below) skips this —
// removing a chip is not a pick, and clobbering whatever the user was
// mid-typing in the search box is a separate, unrelated data loss.
if (!opt.isRemoval) clearQuery(null);
if (effectiveCloseOnSelect()) isOpen = false;
activeIndex = -1;
onchange?.({
value: next,
option: opt.option,
selected: !wasSelected
});
return;
}
value = opt.value;
inputText = String(opt.label);
if (effectiveCloseOnSelect()) isOpen = false;
activeIndex = -1;
// D-15: `selected` is additive and always `true` in single-select.
onchange?.({
value: opt.value,
option: opt.option,
selected: true
});
};
// removeChipValue(v): routes chip removal through the EXACT SAME toggle path
// selectOption() uses for a re-select — a synthetic wrapper row is enough,
// since the `multiple` branch above only reads `opt.value`/`opt.option`/
// `opt.disabled`/`opt.isMore` — so removal and toggle-off can never diverge
// into different payload shapes. Declared here, after selectOption(), not
// alongside chipRows()/chipRemoveLabel() above — see the comment there.
const removeChipValue = (v: any) => {
const opts = Array.isArray(options) ? options : [];
const found = opts.find((o: any) => valueOf(o) === v);
// isRemoval: true tells selectOption()'s `multiple` branch this is a
// removal, not a pick — see the D-14 comment there.
selectOption({
value: v,
option: found || null,
isRemoval: true
});
};
// onChipRemovePointerDown() (quick-260903-0s1, E1 audit finding): the POINTER
// half of the chip remove control's split binding. Deliberately empty —
// the `.prevent` modifier this is bound to (mousedown) is its ENTIRE payload:
// preventDefault on mousedown suppresses the native focus shift, which is
// what keeps the input focused, keeps onBlur() from firing, and therefore
// keeps the popup open (the CR-02 hazard commit `d02a145ef` closed). The
// removal deliberately does NOT live here: preventDefault on mousedown does
// NOT suppress the click that follows it, so a handler bound to BOTH events
// would remove the chip twice per pointer press. See onChipRemoveActivate()
// below for where the removal actually happens.
const onChipRemovePointerDown = () => {};
// onNativeInputChange() (release-0.8.0): the `.stop` on the input's native
// `change` is its whole payload — the native event bubbles out of the inner
// <input> on blur after an edit, and on Angular (no shadow boundary) a consumer
// `(change)` binding on <rozie-combobox> would receive that DOM Event as well as
// the component's own `change` output (the same collision popover's audit B6
// removed). Stopping it keeps `change` meaning only the component event.
const onNativeInputChange = () => {};
// onChipRemoveActivate(v) (quick-260903-0s1, E1 audit finding): the CLICK half
// of the split binding — the actual removal. `click` is the one event every
// activation path produces: a real pointer press (mousedown+click), Enter or
// Space on the focused button (native <button> behavior fires `click`, never
// `keydown`-observable-as-such), AND a screen reader's synthesized activation
// (which emits `click` with no preceding `mousedown` at all — the E1 defect
// this fixes). Binding removal to `click` alone covers all three with exactly
// one removal per activation.
//
// Keyboard/AT activation puts DOM focus ON the button, which this removal
// then unmounts — without an explicit refocus, focus would fall to
// `document.body`. Restore it using the EXACT idiom onFocus() above already
// uses (proven on all six targets): a queued microtask that refocuses
// `$refs.inputEl` only when it exists and is not already `document.activeElement`.
// That activeElement guard is what makes this a strict no-op on the pointer
// path — a pointer press never moves focus off the input in the first place
// (onChipRemovePointerDown's preventDefault sees to that), so this refocus
// never re-enters onFocus() and never re-selects the in-progress query.
// $refs is safe here for the same reason it is safe everywhere else in this
// file: this is a post-mount event handler, not module-init code.
//
// `.stop` on the template's `@click` binding (real-browser VR finding,
// quick-260903-0s1): on Solid and Svelte specifically — the two targets whose
// reactivity applies a DOM mutation SYNCHRONOUSLY, inside the very handler
// that triggered it, rather than batched to a microtask like the other four
// — removing this chip's own `<li>` mid-click detaches the click event's
// `target` from the document BEFORE the event finishes bubbling. Popover's
// own document-level `@click.outside($refs.anchorEl,$refs.floatingEl)`
// dismiss listener (Popover.rozie) then evaluates `anchorEl.contains(target)`
// against the NOW-DETACHED target, which is unconditionally `false` for any
// detached node — misreading this internal removal as an outside click and
// closing the popup. `.stop` (stopPropagation) keeps this click from ever
// reaching that document listener, exactly like the sibling `@mousedown.stop`
// pattern command-palette's own action-menu-affordance row already uses to
// keep an inner gesture from bubbling into an ancestor's own listener.
const onChipRemoveActivate = (v: any) => {
removeChipValue(v);
queueMicrotask(() => {
if (inputEl && document.activeElement !== inputEl) inputEl!.focus();
});
};
// Reflect the externally-selected value into the input text. D-14: no-ops
// under `multiple` — there is no single label to mirror into the input once
// `value` holds an array, and the query is owned by chip-picking instead.
//
// quick-260903-0s1 (E2 audit finding): routed through the SAME valueOf()/
// labelOf() resolvers every other option read in this file uses
// (filteredOptions(), chipRows(), removeChipValue(), queryMatchesOption()) —
// this was the single site that still read the raw `.value`/`.label`
// properties directly. `optionValue`/`optionLabel` are documented public
// props, and the resolvers additionally carry the primitive-option fallback
// (`String(opt)` when `opt` has no `.label`) — bypassing them blanked the
// input on both the mount path ($onMount → syncQueryToValue()) and the
// external-value path ($watch(() => $props.value, ...) → syncQueryToValue()).
//
// The "not found" guard is on `opt` being neither `undefined` NOR `null`,
// deliberately not on truthiness: with primitive options the found entry IS
// the option, so a legitimate selection of an empty string or a zero would be
// discarded by a truthiness test and re-blank the input — reintroducing the
// bug in a new shape. `Array.prototype.find` returns `undefined` on a miss,
// so that is the correct miss test; the `null` check keeps a `null` option
// from rendering as the literal text "null".
const syncQueryToValue = () => {
if (multiple) return;
const opts = Array.isArray(options) ? options : [];
const opt = opts.find((o: any) => valueOf(o) === value);
inputText = opt === undefined || opt === null ? '' : String(labelOf(opt));
};
// ---- free-text commits (COMBOBOX-SPEC items 5-7, multiple only) --------
// delimiterList(): the `delimiters` prop normalized to an array.
const delimiterList = () => Array.isArray(delimiters) ? delimiters : [];
// splitDelimiters(): the CHARACTER delimiters (everything but 'Enter'/'Tab') —
// the paste split characters.
const splitDelimiters = () => delimiterList().filter((k: any) => k !== 'Enter' && k !== 'Tab');
// freeTextOn(): free-text commits are enabled under `multiple` when a delimiter
// list, a validate function, a splitPaste function or commitOnBlur is supplied.
const freeTextOn = () => !!multiple && (delimiterList().length > 0 || typeof validate === 'function' || typeof splitPaste === 'function' || !!commitOnBlur);
// storedText(t): the `validate` gate + normaliser (Tags' shape), for an already
// trimmed, non-empty `t`. Returns the string to store, or null when rejected:
// absent validate ⇒ t; a string return ⇒ that string ('' rejects); any other
// truthy return (`true`) ⇒ t; a falsy return ⇒ rejected.
const storedText = (t: any) => {
if (typeof validate !== 'function') return t;
const r = validate(t);
if (!r) return null;
return typeof r === 'string' ? r : t;
};
// commitTexts(texts): append every not-yet-present text to `value` (ONE fresh
// array, ONE model write) and emit one `change` per committed text, each with the
// running array as of that commit. Texts already present are skipped silently.
const commitTexts = (texts: any) => {
let next = selectedValues();
const committed = [];
const snapshots = [];
for (let i = 0; i < texts.length; i++) {
const t = texts[i];
if (next.indexOf(t) !== -1) continue;
next = next.concat([t]);
committed.push(t);
snapshots.push(next);
}
if (committed.length > 0) value = next;
activeIndex = -1;
for (let i = 0; i < committed.length; i++) {
onchange?.({
value: snapshots[i],
option: null,
selected: true,
text: committed[i]
});
}
};
// syncInputText(el, text): also write the LIVE input element. Angular compares a
// `[value]` binding against its last RENDERED value: fast typing followed by a
// commit in the same frame (before change detection rendered the typed text)
// leaves query '' === last-rendered '' — no DOM write, the typed text stays.
// Writing the element directly is idempotent on every other target.
const syncInputText = (el: any, text: any) => {
if (el && typeof el.value === 'string' && el.value !== text) el.value = text;
};
// setTypedText(q, el): the input text changed to `q` — by typing (onInput) or by a
// paste Combobox handled itself (insertAtCaret). Re-arms the create latch, opens
// the list, highlights the first row and emits `search`, exactly as typing does.
const setTypedText = (q: any, el: any) => {
inputText = q;
syncInputText(el, q);
// Any input change re-arms the double-commit latch (D-17/D-20) — a
// freshly-typed query is a new gesture, never a repeat of whatever was
// last created.
createdQuery = null;
isOpen = true;
activeIndex = 0;
onsearch?.({
query: q
});
};
// clearQuery(el): Combobox clearing the input text ITSELF (a pick under
// `multiple`, a create under `multiple`, a free-text commit, clear()). Emits
// `search` with '' so a host tracking the query through `search` never goes
// stale — a free-text commit of an already-selected value fires no `change`,
// so this is the host's only signal. No emit when the text was already empty.
// The live element is consulted too: on React a commit in the same frame as the
// last keystroke still sees the pre-keystroke `inputText` in its closure.
const clearQuery = (el: any) => {
const had = inputText !== '' || !!(el && typeof el.value === 'string' && el.value !== '');
inputText = '';
syncInputText(el, '');
if (had) onsearch?.({
query: ''
});
};
// insertAtCaret(el, text): insert `text` into the input at the caret, replacing
// the selection — what an ordinary paste does — and leave the caret after it.
const insertAtCaret = (el: any, text: any) => {
const cur = el && typeof el.value === 'string' ? el.value : String(inputText);
const start = el && typeof el.selectionStart === 'number' ? el.selectionStart : cur.length;
const end = el && typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
const next = cur.slice(0, start) + text + cur.slice(end);
setTypedText(next, el);
const caret = start + text.length;
if (el && typeof el.setSelectionRange === 'function') el.setSelectionRange(caret, caret);
};
// commitFreeText(raw, el): trim → validate (normalise) → commit + clear the input.
// Returns true when the text was handled (committed, or already present ⇒ just
// cleared); false when empty or rejected — rejected text stays in the input.
const commitFreeText = (raw: any, el: any) => {
const t = String(raw == null ? '' : raw).trim();
if (!t) return false;
const stored = storedText(t);
if (stored === null) return false;
clearQuery(el);
commitTexts([stored]);
return true;
};
// splitOnDelimiters(text): the built-in paste split — the clipboard text split on
// every CHARACTER delimiter, or null when it contains none (an ordinary paste).
const splitOnDelimiters = (text: any) => {
const seps = splitDelimiters();
let hasSep = false;
for (let s = 0; s < seps.length; s++) {
if (text.indexOf(seps[s]) !== -1) hasSep = true;
}
if (!hasSep) return null;
let parts = [text];
for (let s = 0; s < seps.length; s++) {
const out = [];
for (let p = 0; p < parts.length; p++) {
const pieces = String(parts[p]).split(seps[s]);
for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
}
parts = out;
}
return parts;
};
// onPaste(e) (item 6): under free-text mode the clipboard text is split — by
// `splitPaste` when supplied, else on the character delimiters — and every
// non-empty trimmed part `validate` accepts is committed (the paste is
// preventDefault-ed). The rejected parts (joined by the first delimiter) are
// inserted at the caret, replacing the selection, as an ordinary paste would be,
// so text typed before the paste is kept. A split of null (splitPaste said "not
// mine", or no delimiter in the text) leaves the paste to the browser.
const onPaste = (e: any) => {
if (!freeTextOn()) return;
const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
// typeof checked inline (not via a local flag) so strict TS narrows the call.
const split = typeof splitPaste === 'function' ? splitPaste(text) : splitOnDelimiters(text);
if (!Array.isArray(split)) return;
if (e) e.preventDefault();
const accepted = [];
const rejected = [];
for (let i = 0; i < split.length; i++) {
const part = String(split[i] == null ? '' : split[i]).trim();
if (!part) continue;
const stored = storedText(part);
if (stored === null) rejected.push(part);else accepted.push(stored);
}
const seps = splitDelimiters();
const rest = rejected.join(seps.length > 0 ? seps[0] + ' ' : ' ');
if (rest) insertAtCaret(e ? e.target : null, rest);
commitTexts(accepted);
};
// ---- input + keyboard handlers -----------------------------------------
const onInput = (e: any) => {
const q = e && e.target ? e.target.value : '';
setTypedText(q, null);
};
const onFocus = (e: any) => {
// Phase 86 R2 (plan 86-03), Solid-only reentrancy guard: the input now
// renders inside the composed popover's SCOPED `#anchor` slot
// (`:open="$props.open"` among its params — see the <Popover> template
// comment for why the input moved there). On Solid, a named slot invocation
// with reactive scope params is a plain closure CALL re-run whenever any
// param changes (@rozie/core's documented, intentional Solid
// slot-reactivity design — not a bug to route around at the emitter level):
// the `isOpen` write below changes the `open` param this exact handler is
// responding to, which on Solid SYNCHRONOUSLY recreates the anchor's DOM
// subtree (Solid's JSX has no virtual-DOM diffing to preserve node identity
// across a closure re-invocation) — removing the just-focused `<input>`
// fires a NATIVE blur on it, mid-call-stack, before this function even
// returns. Without the guard below, that blur's own onBlur() would
// immediately set isOpen back to false, and the deferred re-focus further
// down would restart the SAME cycle on the fresh node — an infinite
// recreate/blur/close/refocus loop. `openingInProgress` (below) tells
// onBlur "this blur is a side effect of OUR OWN isOpen write, not the user
// moving focus away" so it can skip closing. The other 5 targets diff their
// scoped-slot re-render and keep the existing, already-focused node — no
// blur ever fires there, so the guard is a no-op for them.
// disableOpenOnFocus (item 3): focus alone never opens the list — typing
// (onInput) and ArrowDown/ArrowUp (onKeydown) still do.
if (disableOpenOnFocus) {
if (e && e.target && e.target.select) e.target.select();
return;
}
openingInProgress = true;
isOpen = true;
// Cleared SYNCHRONOUSLY, immediately after the write — Solid's reactive
// cascade (if any) runs SYNCHRONOUSLY as part of that write, before this
// line executes, so the guard window covers exactly the recreate/blur
// cascade and nothing past it. A deferred (microtask) clear would leave a
// stale `true` window spanning an `await` boundary whenever the re-focus
// below re-enters onFocus, incorrectly suppressing a LATER, genuine blur.
openingInProgress = false;
if (e && e.target && e.target.select) e.target.select();
queueMicrotask(() => {
// Re-assert focus onto whatever node is CURRENT — after Solid's
// synchronous signal-write reactivity (if any) has already run and
// `$refs.inputEl` reflects the latest node — recovering focus if it was
// stranded on a since-removed one.
if (inputEl && document.activeElement !== inputEl) inputEl!.focus();
});
};
// @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.
// While `pinned` (pinOpen(true)), early-return BEFORE the isOpen write — a host
// sub-surface (e.g. command-palette's action flyout) is holding focus and the
// popup must stay open until the host calls pinOpen(false) itself. While
// `openingInProgress` (Solid-only, see onFocus above), early-return too — this
// blur is a side effect of our OWN open-transition recreating the anchor's DOM,
// not the user moving focus elsewhere.
// commitOnBlur: leaving the field commits the typed text through validate (a blur
// into a pinned host sub-surface, or the Solid recreate blur, returned above).
const onBlur = (e: any) => {
if (pinned) return;
if (openingInProgress) return;
isOpen = false;
if (commitOnBlur && freeTextOn()) {
const el = e ? e.target : null;
commitFreeText(el ? el.value : inputText, el);
}
};
const onKeydown = (e: any) => {
// B10: ignore every key while an IME composition is active — the Enter that
// confirms a composition must never pick, commit or navigate. Read through
// `nativeEvent` when present: React's synthetic keyboard event does not carry
// `isComposing` (every other target hands the native event straight through).
const ne = e && e.nativeEvent ? e.nativeEvent : e;
if (ne && (ne.isComposing || ne.keyCode === 229)) return;
const key = e ? e.key : '';
const list = navRows();
// 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;
const visible = popupVisible();
const liveText = e && e.target ? e.target.value : '';
const highlighted = wasOpen && ai >= 0 && list[ai] ? list[ai] : null;
// Character delimiters (item 5): commit the TYPED text — never the highlighted
// option. 'Enter' / 'Tab' entries are handled in their own branches below.
if (freeTextOn() && key !== 'Enter' && key !== 'Tab' && delimiterList().indexOf(key) !== -1) {
if (e) e.preventDefault();
commitFreeText(liveText, e ? e.target : null);
return;
}
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') {
// B9: Enter with Ctrl / Meta / Alt is left to the host (e.g. a send shortcut).
const modified = !!(e && (e.ctrlKey || e.metaKey || e.altKey));
if (!modified) {
if (highlighted) {
if (e) e.preventDefault();
selectOption(highlighted);
} else if (freeTextOn() && String(liveText).trim()) {
// Free-text mode (item 7): Enter with no highlighted option commits the
// typed text (rejected text stays in the input).
if (e) e.preventDefault();
commitFreeText(liveText, e ? e.target : null);
}
}
} else if (key === 'Tab') {
// selectOnTab (item 8): pick the highlighted option while the popup is
// visible; preventDefault ONLY when it picked. A 'Tab' delimiter commits the
// typed text when nothing was picked. Otherwise Tab moves focus normally.
if (selectOnTab && visible && highlighted && !highlighted.disabled) {
if (e) e.preventDefault();
selectOption(highlighted);
} else if (freeTextOn() && delimiterList().indexOf('Tab') !== -1 && String(liveText).trim()) {
if (commitFreeText(liveText, e ? e.target : null) && e) e.preventDefault();
}
} else if (key === 'Escape') {
// B4: only consume Escape when the popup is actually VISIBLE.
if (visible) {
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);
}
} else if (key === 'Backspace') {
// Backspace-removes-last-chip (Tags.rozie precedent, Phase 86 R1 plan
// 86-05): guarded on `multiple` AND the LIVE input value being empty —
// read `e.target.value` directly (Tags' proven idiom), never the mirrored
// `$data.inputText`. A non-empty query falls through to normal text editing —
// nothing here removes a chip while there is text to delete.
if (multiple) {
const liveValue = e && e.target ? e.target.value : '';
if (liveValue === '') {
const cur = selectedValues();
if (cur.length > 0) {
if (e) e.preventDefault();
removeChipValue(cur[cur.length - 1]);
}
}
}
}
// Keep the (new) active option in view — routes through the virtualizer when
// windowing, direct scrollIntoView otherwise.
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: 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);
}
};
// buildVirtualizer() (combobox-virtual-reactivity, VIRT-BUILD): the SINGLE virtualizer
// construction site — called from $onMount below (mount-time virtual:true) AND from the
// virtual $watch further down (a runtime false→true flip), so the mount path can never
// drift from the flip path. Guarded so a build queued (rAF-deferred by the $watch) that
// fires AFTER a flip-back is a no-op (rapid-flip idempotence), and so calling it twice
// never double-constructs.
const buildVirtualizer = () => {
if (!virtual || virtualizer) return;
// 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. 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.
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);
};
// teardownVirtualizer() (VIRT-TEARDOWN): runs the SAME per-instance cleanup fn
// $onUnmount invokes below, then nulls the instance state + bumps windowVer so the
// windowed template branch (still mounted while $props.virtual — CR-01) re-derives to
// the pre-construction fallback state instead of holding a stale virtualizer. This is
// the true→false ResizeObserver-leak fix: previously ONLY $onUnmount ever called
// virtualizerCleanup, so a runtime flip to non-virtual left the observer live.
const teardownVirtualizer = () => {
if (virtualizerCleanup) virtualizerCleanup();
virtualizer = null;
virtualizerCleanup = null;
gridScrollEl = null;
windowVer = windowVer + 1;
};
// nextAutoId(): a page-wide counter shared by every Rozie component instance. It
// lives on globalThis (read through Reflect, which type-checks in the plain-JS and
// the TS script alike) so separately bundled copies of a leaf never hand out the
// same id. The same four lines live in Combobox, Listbox and Popover.
const nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
};
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. seedQuery(text) — imperative-only: write the input text
// (and therefore filteredOptions()'s filter) without touching the `value`
// model or selection state (a command-palette #2 levels/restore-on-pop
// prerequisite — repopulating the input on back-navigation is NOT a
// selection). pinOpen(v) — imperative-only: pin (or unpin) the popup open so
// onBlur() does not collapse it while a host sub-surface holds focus, AND
// (Phase 86-07 regression fix) so the composed Popover's OWN independent
// Escape/click-outside dismissal is vetoed too via `:disable-dismiss`
// (command-palette-sub-actions prerequisite). pinOpen(false) ONLY unpins — it
// does NOT itself close the popup or move focus; that is the host's job.
// Render-neutral when never called. All four are post-mount → $refs safe.
export const focus: () => void = () => inputEl?.focus();
export const clear: () => void = () => {
// Fresh empty array under `multiple` (never in-place mutation), null in
// single mode — mirrors selectOption()'s `{ value, option, selected }`
// shape; nothing is selected after a clear, so `selected` is `false`.
const empty = multiple ? [] : null;
value = empty;
clearQuery(null);
activeIndex = -1;
onchange?.({
value: empty,
option: null,
selected: false
});
};
export const seedQuery: (text: string) => void = (text: any) => {
inputText = String(text == null ? '' : text);
};
export const pinOpen: (v: boolean) => void = (v: any) => {
pinned = !!v;
};
// query() — the current input text (what the last `search` reported).
export const query: () => string = () => inputText;
onMount(() => {
if (!idBase) autoId = 'rozie-combobox-' + nextAutoId();
syncQueryToValue();
syncRows();
didMount = true;
// Routes through the SAME buildVirtualizer() the virtual $watch calls below
// (VIRT-BUILD) — one construction site, so the mount path cannot drift from the flip
// path.
if (virtual) buildVirtualizer();
});
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) + '|' + inputText)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => {
if (expandedGroups && Object.keys(expandedGroups).length) expandedGroups = {};
syncRows();
if (virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
windowVer = windowVer + 1;
scheduleRemeasure();
}
})(); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { (() => virtual)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } (() => {
if (expandedGroups && Object.keys(expandedGroups).length) expandedGroups = {};
if (virtual) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => buildVirtualizer());else setTimeout(() => buildVirtualizer(), 0);
} else {
teardownVirtualizer();
}
})(); }); });
</script>
<div bind:this={__rozieRoot} {...__rozieAttrs} class={["rozie-combobox", { 'rozie-combobox--open': isOpen, 'rozie-combobox--disabled': disabled, 'rozie-combobox--inline': inline, 'rozie-combobox--multiple': multiple, 'rozie-combobox--block': block, 'rozie-combobox--chips-inline': chipsInline() }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-9546115a><Popover trigger="manual" bind:open={isOpen} bare={true} matchWidth={true} keepMounted={virtual} disablePositioning={inline} disableDismiss={inline || pinned} placement={placement} offset={offset} disableFlip={disableFlip} disableShift={disableShift} idBase={idRoot()} data-rozie-s-9546115a>{#snippet anchor()}<div class="rozie-combobox-control" data-rozie-s-9546115a>{#if multiple}<ul class="rozie-combobox-chips" data-rozie-s-9546115a>{#each chipRows() as row, idx ('chip-' + row.value)}<li class="rozie-combobox-chip" data-rozie-s-9546115a>{#if chip}{@render chip({ option: row.option, remove: () => onChipRemoveActivate(row.value), index: idx })}{:else}<span class="rozie-combobox-chip__label" data-rozie-s-9546115a>{rozieDisplay(row.label)}</span><button type="button" class="rozie-combobox-chip__remove" disabled={!!disabled} aria-label={rozieAttr(chipRemoveLabel(row))} onmousedown={($event) => { $event.preventDefault(); onChipRemovePointerDown(); }} onclick={($event) => { $event.stopPropagation(); onChipRemoveActivate(row.value); }} data-rozie-s-9546115a>×</button>{/if}</li>{/each}</ul>{/if}<input bind:this={inputEl} class="rozie-combobox-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded={!!popupVisible()} aria-controls={rozieAttr(listId())} aria-activedescendant={rozieAttr(activeId())} aria-label={ariaLabel} value={inputText} placeholder={placeholder} disabled={!!disabled} autocomplete="off" oninput={($event) => { onInput($event); }} onfocus={($event) => { onFocus($event); }} onblur={($event) => { onBlur($event); }} onkeydown={($event) => { onKeydown($event); }} onpaste={($event) => { onPaste($event); }} onchange={($event) => { $event.stopPropagation(); onNativeInputChange(); }} data-rozie-s-9546115a /></div>{/snippet}{#if popupVisible() && !virtual && !isGrouped()}<ul class="rozie-combobox-list" id={rozieAttr(listId())} role="listbox" aria-multiselectable={rozieAttr(multiple ? 'true' : null)} 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': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }]} id={rozieAttr(optId(opt._i))} role="option" aria-selected={!!isRowSelected(opt)} 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: isRowSelected(opt), disabled: opt.disabled })}{:else}{rozieDisplay(opt.label)}{/if}</li>{/each}{#if filteredOptions().length === 0 && !isCreatableQuery()}<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>{#if empty}{@render empty({ query: inputText })}{:else}No results{/if}</li>{/if}{#if isCreatableQuery()}<li class={["rozie-combobox-option rozie-combobox-create", { 'rozie-combobox-option--active': filteredOptions().length === activeIndex }]} id={rozieAttr(optId(filteredOptions().length))} role="option" onmousedown={($event) => { $event.preventDefault(); selectOption(createRowAt(filteredOptions().length)); }} onmouseenter={($event) => { activeIndex = filteredOptions().length; }} data-rozie-s-9546115a>{#if create}{@render create({ query: inputText })}{:else}Create "{inputText}"{/if}</li>{/if}</ul>{/if}{#if popupVisible() && !virtual && isGrouped() && !isCapped()}<ul class="rozie-combobox-list" id={rozieAttr(listId())} role="listbox" aria-multiselectable={rozieAttr(multiple ? 'true' : null)} data-rozie-s-9546115a>{#each groupBlocks() as blk ('grp-' + (blk.group ? blk.group.id : '_ungrouped'))}<li class="rozie-combobox-group" role="group" aria-label={rozieAttr(blk.group ? blk.group.label : null)} data-rozie-s-9546115a>{#if blk.group}<div class="rozie-combobox-group-heading" role="presentation" data-rozie-s-9546115a>{#if groupHeading}{@render groupHeading({ group: blk.group })}{:else}{rozieDisplay(blk.group.label)}{/if}</div>{/if}{#each blk.items as opt (opt.value)}<div class={["rozie-combobox-option", { 'rozie-combobox-option--active': opt._i === activeIndex, 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }]} id={rozieAttr(optId(opt._i))} role="option" aria-selected={!!isRowSelected(opt)} 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: isRowSelected(opt), disabled: opt.disabled })}{:else}{rozieDisplay(opt.label)}{/if}</div>{/each}</li>{/each}{#if groupBlocks().length === 0 && !isCreatableQuery()}<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>{#if empty}{@render empty({ query: inputText })}{:else}No results{/if}</li>{/if}{#if isCreatableQuery()}<li class={["rozie-combobox-option rozie-combobox-create", { 'rozie-combobox-option--active': filteredOptions().length === activeIndex }]} id={rozieAttr(optId(filteredOptions().length))} role="option" onmousedown={($event) => { $event.preventDefault(); selectOption(createRowAt(filteredOptions().length)); }} onmouseenter={($event) => { activeIndex = filteredOptions().length; }} data-rozie-s-9546115a>{#if create}{@render create({ query: inputText })}{:else}Create "{inputText}"{/if}</li>{/if}</ul>{/if}{#if popupVisible() && !virtual && isCapped()}<ul class="rozie-combobox-list" id={rozieAttr(listId())} role="listbox" aria-multiselectable={rozieAttr(multiple ? 'true' : null)} data-rozie-s-9546115a>{#each cappedBlocks() as blk ('grp-' + (blk.group ? blk.group.id : '_ungrouped'))}<li class="rozie-combobox-group" role="group" aria-label={rozieAttr(blk.group ? blk.group.label : null)} data-rozie-s-9546115a>{#if blk.group}<div class="rozie-combobox-group-heading" role="presentation" data-rozie-s-9546115a>{#if groupHeading}{@render groupHeading({ group: blk.group })}{:else}{rozieDisplay(blk.group.label)}{/if}</div>{/if}{#each blk.items as opt (opt.value)}<div class={["rozie-combobox-option", { 'rozie-combobox-option--active': opt._i === activeIndex, 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }]} id={rozieAttr(optId(opt._i))} role="option" aria-selected={!!isRowSelected(opt)} 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: isRowSelected(opt), disabled: opt.disabled })}{:else}{rozieDisplay(opt.label)}{/if}</div>{/each}{#if blk.more}<div class={["rozie-combobox-option rozie-combobox-more", { 'rozie-combobox-option--active': blk.more._i === activeIndex }]} id={rozieAttr(optId(blk.more._i))} role="option" onmousedown={($event) => { $event.preventDefault(); selectOption(blk.more); }} onmouseenter={($event) => { activeIndex = blk.more._i; }} data-rozie-s-9546115a>{#if groupMore}{@render groupMore({ group: blk.group, hidden: blk.more.hidden, expand: blk.more.expand })}{:else}+{rozieDisplay(blk.more.hidden)} more{/if}</div>{/if}</li>{/each}{#if cappedBlocks().length === 0 && !isCreatableQuery()}<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>{#if empty}{@render empty({ query: inputText })}{:else}No results{/if}</li>{/if}{#if isCreatableQuery()}<li class={["rozie-combobox-option rozie-combobox-create", { 'rozie-combobox-option--active': cappedRowCount() === activeIndex }]} id={rozieAttr(optId(cappedRowCount()))} role="option" onmousedown={($event) => { $event.preventDefault(); selectOption(createRowAt(cappedRowCount())); }} onmouseenter={($event) => { activeIndex = cappedRowCount(); }} data-rozie-s-9546115a>{#if create}{@render create({ query: inputText })}{:else}Create "{inputText}"{/if}</li>{/if}</ul>{/if}{#if virtual}<ul class="rozie-combobox-list rozie-combobox-list--virtual" id={rozieAttr(listId())} role="listbox" aria-multiselectable={rozieAttr(multiple ? 'true' : null)} style={rozieStyle((popupVisible() ? '' : '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 windowedView() as wr (wr.row.id)}<li class={["rozie-combobox-option", { 'rozie-combobox-option--active': wr.vi.index === activeIndex, 'rozie-combobox-option--selected': isRowSelected(wr.row), 'rozie-combobox-option--disabled': wr.row.disabled }]} id={rozieAttr(optId(wr.vi.index))} data-index={rozieAttr(wr.vi.index)} role="option" aria-selected={!!isRowSelected(wr.row)} 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: isRowSelected(wr.row), 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 && !isCreatableQuery()}<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>{#if empty}{@render empty({ query: inputText })}{:else}No results{/if}</li>{/if}{#if isCreatableQuery()}<li class={["rozie-combobox-option rozie-combobox-create", { 'rozie-combobox-option--active': windowSource().length === activeIndex }]} id={rozieAttr(optId(windowSource().length))} role="option" onmousedown={($event) => { $event.preventDefault(); selectOption(createRowAt(windowSource().length)); }} onmouseenter={($event) => { activeIndex = windowSource().length; }} data-rozie-s-9546115a>{#if create}{@render create({ query: inputText })}{:else}Create "{inputText}"{/if}</li>{/if}</ul>{/if}</Popover></div>
<style>
:global {
.rozie-combobox[data-rozie-s-9546115a] {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input[data-rozie-s-9546115a] {
box-sizing: border-box;
/* Phase 86 R2 (plan 86-03): EXPLICIT width, not `100%`. The input now renders
inside popover's `.rozie-popover-anchor` (`display: inline-block`,
shrink-to-fit) rather than as a direct 100%-width child of `.rozie-combobox`
(`width: var(--rozie-combobox-width, var(--rcb-width, 16rem))`) — a percentage width here would
be circular against that shrink-to-fit ancestor (CSS 2.1 §10.3.3: an
unresolvable percentage against an auto-width parent degrades to the
intrinsic/auto size, NOT the control's real width), which is exactly the
bug this fixes: `anchorEl`'s measured rect must equal the input's real box
for Floating UI's positioning AND `matchWidth`'s reference width to be
correct. Reads the SAME `--rozie-combobox-width` token `.rozie-combobox`
itself uses, so the rendered pixel width is IDENTICAL to before this change
in the default (non-inline) case. `.rozie-combobox--inline
.rozie-combobox-input` below restores `100%` for the inline pass-through
path, where `.rozie-combobox` itself stretches to its container (unaffected
by this fix — `disablePositioning` skips anchor measurement entirely there). */
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
padding: var(--rozie-combobox-input-padding, var(--rcb-input-padding, 0.5rem 0.75rem));
font: inherit;
color: var(--rozie-combobox-color, var(--rcb-color, inherit));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
/*
Render-neutral bottom-divider token (260715-50l finding 3). A longhand
AFTER the `border:` shorthand above so it wins on the bottom side; the
fallback REPLICATES the shorthand's own bottom (border-width solid
border-color) so default rendering is byte-for-render unchanged. Lets a
consumer (e.g. command-palette) render a borderless-with-underline input
without touching the other three sides.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25))));
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input[data-rozie-s-9546115a]:focus {
/* Decoupled from --rozie-combobox-accent (finding 3) so a consumer can */
/* neutralize the focus BORDER without touching the selected-option accent. */
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
/*
Same underline token, focus-colored fallback — the longhand keeps
WINNING on the bottom side over the :focus border-color override above,
so a consumer-set divider survives both blurred and focused states.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc))));
}
.rozie-combobox--disabled[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, var(--rcb-disabled-opacity, 0.55));
background: var(--rozie-combobox-disabled-bg, var(--rcb-disabled-bg, rgba(0, 0, 0, 0.04)));
}
.rozie-combobox-list[data-rozie-s-9546115a] {
margin: 0;
padding: var(--rozie-combobox-list-padding, var(--rcb-list-padding, 0.25rem));
list-style: none;
max-height: var(--rozie-combobox-list-max-height, var(--rcb-list-max-height, 16rem));
overflow-y: auto;
background: var(--rozie-combobox-list-bg, var(--rcb-list-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-list-border-color, var(--rcb-list-border-color, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
box-shadow: var(--rozie-combobox-list-shadow, var(--rcb-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16)));
}
.rozie-combobox-option[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-option-padding, var(--rcb-option-padding, 0.4rem 0.6rem));
border-radius: var(--rozie-combobox-option-radius, var(--rcb-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, var(--rcb-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, var(--rcb-option-selected-weight, 600));
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, var(--rcb-option-selected-color, var(--rcb-accent, #0066cc))));
}
.rozie-combobox-option--disabled[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-empty[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-empty-padding, var(--rcb-empty-padding, 0.5rem 0.6rem));
color: var(--rozie-combobox-empty-color, var(--rcb-empty-color, rgba(0, 0, 0, 0.5)));
list-style: none;
}
.rozie-combobox-group[data-rozie-s-9546115a] {
list-style: none;
}
.rozie-combobox-group-heading[data-rozie-s-9546115a] {
/* Render-neutral section-separation token (260715-50l finding 4) — default */
/* 0 = unchanged; a consumer-set value separates the leading ungrouped */
/* block from the first group heading. */
margin-top: var(--rozie-combobox-group-heading-margin-top, var(--rcb-group-heading-margin-top, 0));
padding: var(--rozie-combobox-group-heading-padding, var(--rcb-group-heading-padding, 0.35rem 0.6rem 0.15rem));
font-size: var(--rozie-combobox-group-heading-size, var(--rcb-group-heading-size, 0.75rem));
font-weight: var(--rozie-combobox-group-heading-weight, var(--rcb-group-heading-weight, 600));
text-transform: var(--rozie-combobox-group-heading-transform, var(--rcb-group-heading-transform, uppercase));
letter-spacing: var(--rozie-combobox-group-heading-letter-spacing, var(--rcb-group-heading-letter-spacing, 0.03em));
color: var(--rozie-combobox-group-heading-color, var(--rcb-group-heading-color, rgba(0, 0, 0, 0.5)));
pointer-events: none;
user-select: none;
}
.rozie-combobox-more[data-rozie-s-9546115a] {
cursor: pointer;
color: var(--rozie-combobox-more-color, var(--rcb-more-color, rgba(0, 0, 0, 0.55)));
font-size: var(--rozie-combobox-more-size, var(--rcb-more-size, 0.875rem));
}
.rozie-combobox-create[data-rozie-s-9546115a] {
cursor: pointer;
color: var(--rozie-combobox-create-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
background: var(--rozie-combobox-create-bg, var(--rcb-create-bg, transparent));
}
.rozie-combobox-spacer[data-rozie-s-9546115a] { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox-list--virtual[data-rozie-s-9546115a] { overflow-anchor: none; }
.rozie-combobox-chips[data-rozie-s-9546115a] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
padding: var(--rozie-combobox-chips-padding, var(--rcb-chips-padding, 0.35rem 0.45rem 0 0.45rem));
margin: 0;
list-style: none;
}
.rozie-combobox-chip[data-rozie-s-9546115a] {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: var(--rozie-combobox-chip-padding, var(--rcb-chip-padding, 0.15rem 0.5rem));
font-size: var(--rozie-combobox-chip-size, var(--rcb-chip-size, 0.85rem));
color: var(--rozie-combobox-chip-color, inherit);
background: var(--rozie-combobox-chip-bg, var(--rcb-chip-bg, rgba(0, 102, 204, 0.12)));
border-radius: var(--rozie-combobox-chip-radius, var(--rcb-chip-radius, 0.375rem));
white-space: nowrap;
}
.rozie-combobox-chip__remove[data-rozie-s-9546115a] {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
height: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
padding: 0;
font: inherit;
line-height: 1;
color: var(--rozie-combobox-chip-remove-color, var(--rcb-chip-remove-color, currentColor));
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
transition: color 0.15s;
}
.rozie-combobox-chip__remove[data-rozie-s-9546115a]:hover:not([data-rozie-s-9546115a]:disabled) {
color: var(--rozie-combobox-chip-remove-hover-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
}
.rozie-combobox-chip__remove[data-rozie-s-9546115a]:disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-control[data-rozie-s-9546115a] {
display: contents;
}
.rozie-combobox--block[data-rozie-s-9546115a] {
display: block;
width: 100%;
container-type: inline-size;
}
.rozie-combobox--block[data-rozie-s-9546115a] .rozie-combobox-control[data-rozie-s-9546115a] {
display: block;
width: 100cqw;
}
.rozie-combobox--block[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
width: 100%;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] {
container-type: inline-size;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-control[data-rozie-s-9546115a] {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
width: 100cqw;
padding: var(--rozie-combobox-inline-padding, var(--rcb-inline-padding, 0.3rem 0.45rem));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-control[data-rozie-s-9546115a]:focus-within {
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-chips[data-rozie-s-9546115a] {
display: contents;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a],
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a]:focus {
flex: 1 1 var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
width: auto;
min-width: var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
padding: var(--rozie-combobox-inline-input-padding, var(--rcb-inline-input-padding, 0.2rem 0.25rem));
background: transparent;
border: none;
box-shadow: 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` dropped (plan 86-03): `.rozie-combobox-list` carries no
absolute positioning to undo anymore — that geometry lives on popover's
`.rozie-popover-floating`, and `:disable-positioning="$props.inline"`
(D-09) already renders it as a static pass-through via popover's own
`.rozie-popover-floating--static` rule. */
margin-top: var(--rozie-combobox-list-gap, var(--rcb-list-gap, 0.25rem));
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-combobox--inline[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
width: 100%;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, contentChildren, 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';
import { RozieSlot, createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay } from '@rozie/runtime-angular';
import { Popover } from '@rozie-ui/popover-angular';
// 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';
// ---- native option grouping (combobox-native-groups: src/internal/groupOptions.ts) ----
// The PURE stable-partition helper is a RUNTIME import (unlike listCore/windowing
// above, it is NOT a compile-time `.rzts` partial that dissolves at compile) —
// codegen's `copyInternal` vendors it verbatim into each leaf at
// `./internal/groupOptions`, mirroring command-palette's `scoreCommands.ts`.
import { groupOptions } from './internal/groupOptions';
// 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.
// The typed public surface (typed-surface P1; always TypeScript). `value` /
// `option` stay `any`: options are consumer-shaped objects (or primitives) the
// component never inspects beyond the label/value/disabled resolvers.
/** `search` payload — the current input text. */
export interface ComboboxSearchPayload {
query: string;
}
/** `change` payload — `option` is the raw source option (`null` for a clear or a free-text commit); `text` is set ONLY on free-text commits. */
export interface ComboboxChangePayload {
value: any;
option: any;
selected: boolean;
text?: string;
}
/** `create` payload — the (untrimmed) query the user asked to create. */
export interface ComboboxCreatePayload {
query: string;
}
/** An entry of the `groups` prop. */
export interface ComboboxGroup {
id: string;
label: string;
}
/** `chip` slot params — `remove()` removes the chip and refocuses the input. */
export interface ComboboxChipSlotCtx {
option: any;
remove: () => void;
index: number;
}
/** `option` slot params. */
export interface ComboboxOptionSlotCtx {
option: any;
index: number;
active: boolean;
selected: boolean;
disabled: boolean;
}
/** `empty` / `create` slot params. */
export interface ComboboxQuerySlotCtx {
query: string;
}
/** `groupHeading` slot params. */
export interface ComboboxGroupHeadingSlotCtx {
group: ComboboxGroup;
}
/** `groupMore` slot params. */
export interface ComboboxGroupMoreSlotCtx {
group: ComboboxGroup | null;
hidden: number;
expand: () => void;
}
interface ChipCtx {
$implicit: { option: any; remove: () => void; index: number };
option: any;
remove: () => void;
index: number;
}
interface OptionCtx {
$implicit: { option: any; index: number; active: boolean; selected: boolean; disabled: boolean };
option: any;
index: number;
active: boolean;
selected: boolean;
disabled: boolean;
}
interface EmptyCtx {
$implicit: { query: string };
query: string;
}
interface CreateCtx {
$implicit: { query: string };
query: string;
}
interface GroupHeadingCtx {
$implicit: { group: ComboboxGroup };
group: ComboboxGroup;
}
interface GroupMoreCtx {
$implicit: { group: ComboboxGroup | null; hidden: number; expand: () => void };
group: ComboboxGroup | null;
hidden: number;
expand: () => void;
}
@Component({
selector: 'rozie-combobox',
standalone: true,
imports: [NgTemplateOutlet, NgClass, Popover],
template: `
<div class="rozie-combobox" [ngClass]="{ 'rozie-combobox--open': isOpen(), 'rozie-combobox--disabled': (disabled() || this.__rozieCvaDisabled()), 'rozie-combobox--inline': inline(), 'rozie-combobox--multiple': multiple(), 'rozie-combobox--block': block(), 'rozie-combobox--chips-inline': chipsInline() }" #__rozieRoot #rozieSpread_0 #rozieListenersTarget_1>
<rozie-popover trigger="manual" [open]="isOpen()" (openChange)="isOpen.set($event)" [bare]="true" [matchWidth]="true" [keepMounted]="virtual()" [disablePositioning]="inline()" [disableDismiss]="inline() || pinned()" [placement]="placement()" [offset]="offset()" [disableFlip]="disableFlip()" [disableShift]="disableShift()" [idBase]="idRoot()"><ng-template #anchor>
<div class="rozie-combobox-control">
@if (multiple()) {
<ul class="rozie-combobox-chips">
@for (row of chipRows(); track 'chip-' + row.value; let idx = $index) {
<li class="rozie-combobox-chip">
@if ((chipTpl ?? __rozieFillMap()['chip'] ?? templates()?.['chip'])) {
<ng-container *ngTemplateOutlet="(chipTpl ?? __rozieFillMap()['chip'] ?? templates()?.['chip']); context: _chip_ctx_2(row, idx)" />
} @else {
<span class="rozie-combobox-chip__label">{{ rozieDisplay(row.label) }}</span>
<button type="button" class="rozie-combobox-chip__remove" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [attr.aria-label]="rozieAttr(chipRemoveLabel(row))" (mousedown)="$event.preventDefault(); onChipRemovePointerDown()" (click)="$event.stopPropagation(); onChipRemoveActivate(row.value)">×</button>
}
</li>
}
</ul>
}<input #inputEl class="rozie-combobox-input" type="text" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="!!popupVisible()" [attr.aria-controls]="rozieAttr(listId())" [attr.aria-activedescendant]="rozieAttr(activeId())" [attr.aria-label]="rozieAttr(ariaLabel())" [value]="inputText()" [placeholder]="placeholder()" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" autocomplete="off" (input)="onInput($event)" (focus)="onFocus($event)" (blur)="onBlur($event)" (keydown)="onKeydown($event)" (paste)="onPaste($event)" (change)="$event.stopPropagation(); onNativeInputChange()" />
</div>
</ng-template><ng-template #defaultSlot>
@if (popupVisible() && !virtual() && !isGrouped()) {
<ul class="rozie-combobox-list" [attr.id]="rozieAttr(listId())" role="listbox" [attr.aria-multiselectable]="rozieAttr(multiple() ? 'true' : null)">
@for (opt of filteredOptions(); track opt.value) {
<li class="rozie-combobox-option" [ngClass]="{ 'rozie-combobox-option--active': opt._i === activeIndex(), 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }" [attr.id]="rozieAttr(optId(opt._i))" role="option" [attr.aria-selected]="!!isRowSelected(opt)" [attr.aria-disabled]="!!opt.disabled" (mousedown)="$event.preventDefault(); selectOption(opt)" (mouseenter)="activeIndex.set(opt._i)">
@if ((optionTpl ?? __rozieFillMap()['option'] ?? templates()?.['option'])) {
<ng-container *ngTemplateOutlet="(optionTpl ?? __rozieFillMap()['option'] ?? templates()?.['option']); context: { $implicit: { option: opt.option, index: opt._i, active: opt._i === activeIndex(), selected: isRowSelected(opt), disabled: opt.disabled }, option: opt.option, index: opt._i, active: opt._i === activeIndex(), selected: isRowSelected(opt), disabled: opt.disabled }" />
} @else {
{{ rozieDisplay(opt.label) }}
}
</li>
}
@if (filteredOptions().length === 0 && !isCreatableQuery()) {
<li class="rozie-combobox-empty" role="presentation">
@if ((emptyTpl ?? __rozieFillMap()['empty'] ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? __rozieFillMap()['empty'] ?? templates()?.['empty']); context: { $implicit: { query: inputText() }, query: inputText() }" />
} @else {
No results
}
</li>
}@if (isCreatableQuery()) {
<li class="rozie-combobox-option rozie-combobox-create" [ngClass]="{ 'rozie-combobox-option--active': filteredOptions().length === activeIndex() }" [attr.id]="rozieAttr(optId(filteredOptions().length))" role="option" (mousedown)="$event.preventDefault(); selectOption(createRowAt(filteredOptions().length))" (mouseenter)="activeIndex.set(filteredOptions().length)">
@if ((createTpl ?? __rozieFillMap()['create'] ?? templates()?.['create'])) {
<ng-container *ngTemplateOutlet="(createTpl ?? __rozieFillMap()['create'] ?? templates()?.['create']); context: { $implicit: { query: inputText() }, query: inputText() }" />
} @else {
Create "{{ inputText() }}"
}
</li>
}</ul>
}@if (popupVisible() && !virtual() && isGrouped() && !isCapped()) {
<ul class="rozie-combobox-list" [attr.id]="rozieAttr(listId())" role="listbox" [attr.aria-multiselectable]="rozieAttr(multiple() ? 'true' : null)">
@for (blk of groupBlocks(); track 'grp-' + (blk.group ? blk.group.id : '_ungrouped')) {
<li class="rozie-combobox-group" role="group" [attr.aria-label]="rozieAttr(blk.group ? blk.group.label : null)">
@if (blk.group) {
<div class="rozie-combobox-group-heading" role="presentation">
@if ((groupHeadingTpl ?? __rozieFillMap()['groupHeading'] ?? templates()?.['groupHeading'])) {
<ng-container *ngTemplateOutlet="(groupHeadingTpl ?? __rozieFillMap()['groupHeading'] ?? templates()?.['groupHeading']); context: { $implicit: { group: blk.group }, group: blk.group }" />
} @else {
{{ rozieDisplay(blk.group.label) }}
}
</div>
}@for (opt of blk.items; track opt.value) {
<div class="rozie-combobox-option" [ngClass]="{ 'rozie-combobox-option--active': opt._i === activeIndex(), 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }" [attr.id]="rozieAttr(optId(opt._i))" role="option" [attr.aria-selected]="!!isRowSelected(opt)" [attr.aria-disabled]="!!opt.disabled" (mousedown)="$event.preventDefault(); selectOption(opt)" (mouseenter)="activeIndex.set(opt._i)">
@if ((optionTpl ?? __rozieFillMap()['option'] ?? templates()?.['option'])) {
<ng-container *ngTemplateOutlet="(optionTpl ?? __rozieFillMap()['option'] ?? templates()?.['option']); context: { $implicit: { option: opt.option, index: opt._i, active: opt._i === activeIndex(), selected: isRowSelected(opt), disabled: opt.disabled }, option: opt.option, index: opt._i, active: opt._i === activeIndex(), selected: isRowSelected(opt), disabled: opt.disabled }" />
} @else {
{{ rozieDisplay(opt.label) }}
}
</div>
}
</li>
}
@if (groupBlocks().length === 0 && !isCreatableQuery()) {
<li class="rozie-combobox-empty" role="presentation">
@if ((emptyTpl ?? __rozieFillMap()['empty'] ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? __rozieFillMap()['empty'] ?? templates()?.['empty']); context: { $implicit: { query: inputText() }, query: inputText() }" />
} @else {
No results
}
</li>
}@if (isCreatableQuery()) {
<li class="rozie-combobox-option rozie-combobox-create" [ngClass]="{ 'rozie-combobox-option--active': filteredOptions().length === activeIndex() }" [attr.id]="rozieAttr(optId(filteredOptions().length))" role="option" (mousedown)="$event.preventDefault(); selectOption(createRowAt(filteredOptions().length))" (mouseenter)="activeIndex.set(filteredOptions().length)">
@if ((createTpl ?? __rozieFillMap()['create'] ?? templates()?.['create'])) {
<ng-container *ngTemplateOutlet="(createTpl ?? __rozieFillMap()['create'] ?? templates()?.['create']); context: { $implicit: { query: inputText() }, query: inputText() }" />
} @else {
Create "{{ inputText() }}"
}
</li>
}</ul>
}@if (popupVisible() && !virtual() && isCapped()) {
<ul class="rozie-combobox-list" [attr.id]="rozieAttr(listId())" role="listbox" [attr.aria-multiselectable]="rozieAttr(multiple() ? 'true' : null)">
@for (blk of cappedBlocks(); track 'grp-' + (blk.group ? blk.group.id : '_ungrouped')) {
<li class="rozie-combobox-group" role="group" [attr.aria-label]="rozieAttr(blk.group ? blk.group.label : null)">
@if (blk.group) {
<div class="rozie-combobox-group-heading" role="presentation">
@if ((groupHeadingTpl ?? __rozieFillMap()['groupHeading'] ?? templates()?.['groupHeading'])) {
<ng-container *ngTemplateOutlet="(groupHeadingTpl ?? __rozieFillMap()['groupHeading'] ?? templates()?.['groupHeading']); context: { $implicit: { group: blk.group }, group: blk.group }" />
} @else {
{{ rozieDisplay(blk.group.label) }}
}
</div>
}@for (opt of blk.items; track opt.value) {
<div class="rozie-combobox-option" [ngClass]="{ 'rozie-combobox-option--active': opt._i === activeIndex(), 'rozie-combobox-option--selected': isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }" [attr.id]="rozieAttr(optId(opt._i))" role="option" [attr.aria-selected]="!!isRowSelected(opt)" [attr.aria-disabled]="!!opt.disabled" (mousedown)="$event.preventDefault(); selectOption(opt)" (mouseenter)="activeIndex.set(opt._i)">
@if ((optionTpl ?? __rozieFillMap()['option'] ?? templates()?.['option'])) {
<ng-container *ngTemplateOutlet="(optionTpl ?? __rozieFillMap()['option'] ?? templates()?.['option']); context: { $implicit: { option: opt.option, index: opt._i, active: opt._i === activeIndex(), selected: isRowSelected(opt), disabled: opt.disabled }, option: opt.option, index: opt._i, active: opt._i === activeIndex(), selected: isRowSelected(opt), disabled: opt.disabled }" />
} @else {
{{ rozieDisplay(opt.label) }}
}
</div>
}
@if (blk.more) {
<div class="rozie-combobox-option rozie-combobox-more" [ngClass]="{ 'rozie-combobox-option--active': blk.more._i === activeIndex() }" [attr.id]="rozieAttr(optId(blk.more._i))" role="option" (mousedown)="$event.preventDefault(); selectOption(blk.more)" (mouseenter)="activeIndex.set(blk.more._i)">
@if ((groupMoreTpl ?? __rozieFillMap()['groupMore'] ?? templates()?.['groupMore'])) {
<ng-container *ngTemplateOutlet="(groupMoreTpl ?? __rozieFillMap()['groupMore'] ?? templates()?.['groupMore']); context: { $implicit: { group: blk.group, hidden: blk.more.hidden, expand: blk.more.expand }, group: blk.group, hidden: blk.more.hidden, expand: blk.more.expand }" />
} @else {
+{{ rozieDisplay(blk.more.hidden) }} more
}
</div>
}</li>
}
@if (cappedBlocks().length === 0 && !isCreatableQuery()) {
<li class="rozie-combobox-empty" role="presentation">
@if ((emptyTpl ?? __rozieFillMap()['empty'] ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? __rozieFillMap()['empty'] ?? templates()?.['empty']); context: { $implicit: { query: inputText() }, query: inputText() }" />
} @else {
No results
}
</li>
}@if (isCreatableQuery()) {
<li class="rozie-combobox-option rozie-combobox-create" [ngClass]="{ 'rozie-combobox-option--active': cappedRowCount() === activeIndex() }" [attr.id]="rozieAttr(optId(cappedRowCount()))" role="option" (mousedown)="$event.preventDefault(); selectOption(createRowAt(cappedRowCount()))" (mouseenter)="activeIndex.set(cappedRowCount())">
@if ((createTpl ?? __rozieFillMap()['create'] ?? templates()?.['create'])) {
<ng-container *ngTemplateOutlet="(createTpl ?? __rozieFillMap()['create'] ?? templates()?.['create']); context: { $implicit: { query: inputText() }, query: inputText() }" />
} @else {
Create "{{ inputText() }}"
}
</li>
}</ul>
}@if (virtual()) {
<ul class="rozie-combobox-list rozie-combobox-list--virtual" [attr.id]="rozieAttr(listId())" role="listbox" [attr.aria-multiselectable]="rozieAttr(multiple() ? 'true' : null)" [attr.style]="__style">
<li class="rozie-combobox-spacer" aria-hidden="true" [attr.style]="'height:' + padTop() + 'px'"></li>
@for (wr of windowedView(); track wr.row.id) {
<li class="rozie-combobox-option" [ngClass]="{ 'rozie-combobox-option--active': wr.vi.index === activeIndex(), 'rozie-combobox-option--selected': isRowSelected(wr.row), '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]="!!isRowSelected(wr.row)" [attr.aria-disabled]="!!wr.row.disabled" (mousedown)="$event.preventDefault(); selectOption(wr.row)" (mouseenter)="activeIndex.set(wr.vi.index)">
@if ((optionTpl ?? __rozieFillMap()['option'] ?? templates()?.['option'])) {
<ng-container *ngTemplateOutlet="(optionTpl ?? __rozieFillMap()['option'] ?? templates()?.['option']); context: { $implicit: { option: wr.row.option, index: wr.vi.index, active: wr.vi.index === activeIndex(), selected: isRowSelected(wr.row), disabled: wr.row.disabled }, option: wr.row.option, index: wr.vi.index, active: wr.vi.index === activeIndex(), selected: isRowSelected(wr.row), disabled: wr.row.disabled }" />
} @else {
{{ rozieDisplay(wr.row.label) }}
}
</li>
}
<li class="rozie-combobox-spacer" aria-hidden="true" [attr.style]="'height:' + padBottom() + 'px'"></li>
@if (windowSource().length === 0 && !isCreatableQuery()) {
<li class="rozie-combobox-empty" role="presentation">
@if ((emptyTpl ?? __rozieFillMap()['empty'] ?? templates()?.['empty'])) {
<ng-container *ngTemplateOutlet="(emptyTpl ?? __rozieFillMap()['empty'] ?? templates()?.['empty']); context: { $implicit: { query: inputText() }, query: inputText() }" />
} @else {
No results
}
</li>
}@if (isCreatableQuery()) {
<li class="rozie-combobox-option rozie-combobox-create" [ngClass]="{ 'rozie-combobox-option--active': windowSource().length === activeIndex() }" [attr.id]="rozieAttr(optId(windowSource().length))" role="option" (mousedown)="$event.preventDefault(); selectOption(createRowAt(windowSource().length))" (mouseenter)="activeIndex.set(windowSource().length)">
@if ((createTpl ?? __rozieFillMap()['create'] ?? templates()?.['create'])) {
<ng-container *ngTemplateOutlet="(createTpl ?? __rozieFillMap()['create'] ?? templates()?.['create']); context: { $implicit: { query: inputText() }, query: inputText() }" />
} @else {
Create "{{ inputText() }}"
}
</li>
}</ul>
}</ng-template></rozie-popover>
</div>
`,
styles: [`
:host(rozie-combobox) { display: contents; }
.rozie-combobox {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input {
box-sizing: border-box;
/* Phase 86 R2 (plan 86-03): EXPLICIT width, not \`100%\`. The input now renders
inside popover's \`.rozie-popover-anchor\` (\`display: inline-block\`,
shrink-to-fit) rather than as a direct 100%-width child of \`.rozie-combobox\`
(\`width: var(--rozie-combobox-width, var(--rcb-width, 16rem))\`) — a percentage width here would
be circular against that shrink-to-fit ancestor (CSS 2.1 §10.3.3: an
unresolvable percentage against an auto-width parent degrades to the
intrinsic/auto size, NOT the control's real width), which is exactly the
bug this fixes: \`anchorEl\`'s measured rect must equal the input's real box
for Floating UI's positioning AND \`matchWidth\`'s reference width to be
correct. Reads the SAME \`--rozie-combobox-width\` token \`.rozie-combobox\`
itself uses, so the rendered pixel width is IDENTICAL to before this change
in the default (non-inline) case. \`.rozie-combobox--inline
.rozie-combobox-input\` below restores \`100%\` for the inline pass-through
path, where \`.rozie-combobox\` itself stretches to its container (unaffected
by this fix — \`disablePositioning\` skips anchor measurement entirely there). */
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
padding: var(--rozie-combobox-input-padding, var(--rcb-input-padding, 0.5rem 0.75rem));
font: inherit;
color: var(--rozie-combobox-color, var(--rcb-color, inherit));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
/*
Render-neutral bottom-divider token (260715-50l finding 3). A longhand
AFTER the \`border:\` shorthand above so it wins on the bottom side; the
fallback REPLICATES the shorthand's own bottom (border-width solid
border-color) so default rendering is byte-for-render unchanged. Lets a
consumer (e.g. command-palette) render a borderless-with-underline input
without touching the other three sides.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25))));
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input:focus {
/* Decoupled from --rozie-combobox-accent (finding 3) so a consumer can */
/* neutralize the focus BORDER without touching the selected-option accent. */
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
/*
Same underline token, focus-colored fallback — the longhand keeps
WINNING on the bottom side over the :focus border-color override above,
so a consumer-set divider survives both blurred and focused states.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc))));
}
.rozie-combobox--disabled .rozie-combobox-input {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, var(--rcb-disabled-opacity, 0.55));
background: var(--rozie-combobox-disabled-bg, var(--rcb-disabled-bg, rgba(0, 0, 0, 0.04)));
}
.rozie-combobox-list {
margin: 0;
padding: var(--rozie-combobox-list-padding, var(--rcb-list-padding, 0.25rem));
list-style: none;
max-height: var(--rozie-combobox-list-max-height, var(--rcb-list-max-height, 16rem));
overflow-y: auto;
background: var(--rozie-combobox-list-bg, var(--rcb-list-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-list-border-color, var(--rcb-list-border-color, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
box-shadow: var(--rozie-combobox-list-shadow, var(--rcb-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16)));
}
.rozie-combobox-option {
padding: var(--rozie-combobox-option-padding, var(--rcb-option-padding, 0.4rem 0.6rem));
border-radius: var(--rozie-combobox-option-radius, var(--rcb-option-radius, 0.375rem));
cursor: pointer;
color: var(--rozie-combobox-option-color, inherit);
}
.rozie-combobox-option--active {
background: var(--rozie-combobox-option-active-bg, var(--rcb-option-active-bg, rgba(0, 102, 204, 0.12)));
}
.rozie-combobox-option--selected {
font-weight: var(--rozie-combobox-option-selected-weight, var(--rcb-option-selected-weight, 600));
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, var(--rcb-option-selected-color, var(--rcb-accent, #0066cc))));
}
.rozie-combobox-option--disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-empty {
padding: var(--rozie-combobox-empty-padding, var(--rcb-empty-padding, 0.5rem 0.6rem));
color: var(--rozie-combobox-empty-color, var(--rcb-empty-color, rgba(0, 0, 0, 0.5)));
list-style: none;
}
.rozie-combobox-group {
list-style: none;
}
.rozie-combobox-group-heading {
/* Render-neutral section-separation token (260715-50l finding 4) — default */
/* 0 = unchanged; a consumer-set value separates the leading ungrouped */
/* block from the first group heading. */
margin-top: var(--rozie-combobox-group-heading-margin-top, var(--rcb-group-heading-margin-top, 0));
padding: var(--rozie-combobox-group-heading-padding, var(--rcb-group-heading-padding, 0.35rem 0.6rem 0.15rem));
font-size: var(--rozie-combobox-group-heading-size, var(--rcb-group-heading-size, 0.75rem));
font-weight: var(--rozie-combobox-group-heading-weight, var(--rcb-group-heading-weight, 600));
text-transform: var(--rozie-combobox-group-heading-transform, var(--rcb-group-heading-transform, uppercase));
letter-spacing: var(--rozie-combobox-group-heading-letter-spacing, var(--rcb-group-heading-letter-spacing, 0.03em));
color: var(--rozie-combobox-group-heading-color, var(--rcb-group-heading-color, rgba(0, 0, 0, 0.5)));
pointer-events: none;
user-select: none;
}
.rozie-combobox-more {
cursor: pointer;
color: var(--rozie-combobox-more-color, var(--rcb-more-color, rgba(0, 0, 0, 0.55)));
font-size: var(--rozie-combobox-more-size, var(--rcb-more-size, 0.875rem));
}
.rozie-combobox-create {
cursor: pointer;
color: var(--rozie-combobox-create-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
background: var(--rozie-combobox-create-bg, var(--rcb-create-bg, transparent));
}
.rozie-combobox-spacer { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox-list--virtual { overflow-anchor: none; }
.rozie-combobox-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
padding: var(--rozie-combobox-chips-padding, var(--rcb-chips-padding, 0.35rem 0.45rem 0 0.45rem));
margin: 0;
list-style: none;
}
.rozie-combobox-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: var(--rozie-combobox-chip-padding, var(--rcb-chip-padding, 0.15rem 0.5rem));
font-size: var(--rozie-combobox-chip-size, var(--rcb-chip-size, 0.85rem));
color: var(--rozie-combobox-chip-color, inherit);
background: var(--rozie-combobox-chip-bg, var(--rcb-chip-bg, rgba(0, 102, 204, 0.12)));
border-radius: var(--rozie-combobox-chip-radius, var(--rcb-chip-radius, 0.375rem));
white-space: nowrap;
}
.rozie-combobox-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
height: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
padding: 0;
font: inherit;
line-height: 1;
color: var(--rozie-combobox-chip-remove-color, var(--rcb-chip-remove-color, currentColor));
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
transition: color 0.15s;
}
.rozie-combobox-chip__remove:hover:not(:disabled) {
color: var(--rozie-combobox-chip-remove-hover-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
}
.rozie-combobox-chip__remove:disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-control {
display: contents;
}
.rozie-combobox--block {
display: block;
width: 100%;
container-type: inline-size;
}
.rozie-combobox--block .rozie-combobox-control {
display: block;
width: 100cqw;
}
.rozie-combobox--block .rozie-combobox-input {
width: 100%;
}
.rozie-combobox--chips-inline {
container-type: inline-size;
}
.rozie-combobox--chips-inline .rozie-combobox-control {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
width: 100cqw;
padding: var(--rozie-combobox-inline-padding, var(--rcb-inline-padding, 0.3rem 0.45rem));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox--chips-inline .rozie-combobox-control:focus-within {
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
}
.rozie-combobox--chips-inline .rozie-combobox-chips {
display: contents;
}
.rozie-combobox--chips-inline .rozie-combobox-input,
.rozie-combobox--chips-inline .rozie-combobox-input:focus {
flex: 1 1 var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
width: auto;
min-width: var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
padding: var(--rozie-combobox-inline-input-padding, var(--rcb-inline-input-padding, 0.2rem 0.25rem));
background: transparent;
border: none;
box-shadow: none;
}
.rozie-combobox--inline {
display: block;
width: 100%;
}
.rozie-combobox--inline .rozie-combobox-list {
/* \`position: static\` dropped (plan 86-03): \`.rozie-combobox-list\` carries no
absolute positioning to undo anymore — that geometry lives on popover's
\`.rozie-popover-floating\`, and \`:disable-positioning="$props.inline"\`
(D-09) already renders it as a static pass-through via popover's own
\`.rozie-popover-floating--static\` rule. */
margin-top: var(--rozie-combobox-list-gap, var(--rcb-list-gap, 0.25rem));
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-combobox--inline .rozie-combobox-input {
width: 100%;
}
`],
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
* <rozie-combobox [(value)]="country" [options]="countries" />
*/
value = model<(unknown) | null>(null);
/**
* The option list — `[{ value, label, disabled?, group? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, an optional `disabled` flag makes an option non-selectable, and an optional `group` string buckets the option under a matching entry of the `groups` prop (or a first-appearance fallback section) when grouping is active.
*/
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, option and popup elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`, the listbox id is `idBase + "-list"`. Leave it empty (the default) and each instance generates a unique id base after mount (`rozie-combobox-<n>`); set it when you need stable, predictable ids. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
idBase = input<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 = input<boolean>(false);
/**
* Close the popup after a selection commits. Unset (default) resolves through `effectiveCloseOnSelect()`: `true` in single-select (today's default behavior) and `false` in `multiple` mode, where closing after every chip pick would make multi-select unusable. Pass an explicit `true` or `false` to override in either mode.
*/
closeOnSelect = input<(boolean) | null>(null);
/**
* `value` widens to hold an **array** of selected values and remains the sole `model: true` prop, so the Angular `ControlValueAccessor` is preserved (a second model would forfeit it — `ROZ125`). Re-selecting an already-selected option toggles it off. Default `false` is byte-identical to single-select.
*/
multiple = input<boolean>(false);
/**
* When the user commits text matching no option (case-insensitive, trimmed, exact label equality — no Unicode normalization applied), combobox emits `create` with the query and writes NOTHING to `value` — the consumer adds the option to `options` and updates the model itself. Composes with `multiple`. Turning this on replaces the `#empty` fill with the `#create` row whenever the query is creatable (non-empty, no exact match); `#empty` still renders for an empty or whitespace-only query. Default `false` is byte-identical to today.
*/
creatable = input<boolean>(false);
/**
* Resolver override for an object option's display label — `(option) => string`. Falls back to the option's `.label` property.
*/
optionLabel = input<((...args: any[]) => any) | null>(null);
/**
* Resolver override for an object option's committed value — `(option) => value`. Falls back to the option's `.value` property.
*/
optionValue = input<((...args: any[]) => any) | null>(null);
/**
* Resolver override marking an option non-selectable — `(option) => boolean`. Falls back to the option's `.disabled` property.
*/
optionDisabled = input<((...args: any[]) => any) | 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>('');
/**
* Ordered section list `[{ id, label }]` setting group order + heading text. Options are partitioned by their optional `group?` string; groups present on options but absent here fall back to first-appearance order after the listed ones. Empty/absent ⇒ flat, ungrouped rendering (default).
*/
groups = input<any[]>((() => [])());
/**
* Cap each native section group to its first `groupCap` results, adding a keyboard-reachable '+N more' row that expands that group IN PLACE when activated. `0`/absent = uncapped (default). Only applies to the non-virtual grouped render (`groups` non-empty); ignored when `virtual` is on.
*/
groupCap = input<number>(0);
/**
* Floating UI placement of the popup relative to the control, forwarded to the composed `@rozie-ui/popover` leaf — one of `top`/`right`/`bottom`/`left`, each optionally suffixed `-start`/`-end`. Default `"bottom-start"` matches the pre-Phase-86 static popup alignment (flush with the control's left edge). Ignored when `inline` is set.
*/
placement = input<string>('bottom-start');
/**
* Gap in pixels between the control and the popup, forwarded to the composed `@rozie-ui/popover` leaf. Default `4` preserves the pre-Phase-86 resting gap (`--rozie-combobox-list-gap`). Ignored when `inline` is set.
*/
offset = input<number>(4);
/**
* Disable the popup's Floating UI `flip` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup flips above the control when it would overflow the viewport below; set this to keep it pinned to `placement` regardless. Ignored when `inline` is set.
*/
disableFlip = input<boolean>(false);
/**
* Disable the popup's Floating UI `shift` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup shifts to stay within the viewport; set this to keep it strictly aligned to the control. Ignored when `inline` is set.
*/
disableShift = input<boolean>(false);
/**
* Fill the container: the root becomes `display: block; width: 100%`, the control (chips + input) stretches to that width, and the width-matched popup follows. Adds the `rozie-combobox--block` modifier class on the root. Default `false` keeps the fixed `--rozie-combobox-width` sizing.
*/
block = input<boolean>(false);
/**
* Chip rail layout under `multiple`: `'stacked'` (default) renders the chips above the input; `'inline'` puts the chips and the input on ONE wrapping row (the Tags layout), with the input taking the remaining width (`flex: 1`, never narrower than `--rozie-combobox-inline-input-min-width`). Only meaningful with `multiple`.
*/
chipLayout = input<string>('stacked');
/**
* Do not open the list when the input gains focus. Typing and ArrowDown / ArrowUp still open it. Default `false` opens on focus.
*/
disableOpenOnFocus = input<boolean>(false);
/**
* Show nothing instead of the empty state: when there are no option rows and no create row, the popup is not shown, the input reports `aria-expanded="false"`, and Escape is left to the host (not `preventDefault`ed). This is the supported way to render no popup at all; filling the `empty` slot with nothing still renders the fallback on most targets.
*/
hideEmpty = input<boolean>(false);
/**
* Keys that commit the **typed text** as a value (matched against the key event's `key`), under `multiple` only — a delimiter never picks the highlighted option. Character entries (e.g. `[',', ';']`) also split pasted text: a paste containing a delimiter is split on them, every non-empty trimmed part that `validate` accepts is committed, and the rejected parts are inserted at the caret (replacing the selection) like an ordinary paste, so text typed before the paste is kept. Use `splitPaste` to replace this split. `'Enter'` and `'Tab'` are allowed; Enter then commits the typed text only when no option is highlighted. A non-empty list (or `validate`, `splitPaste` or `commitOnBlur`) turns on free-text commits, so Enter with no highlighted option commits the typed text too. Default `[]` (off).
* @example
* <rozie-combobox multiple [(value)]="to" [options]="contacts" [delimiters]="delims" />
*/
delimiters = input<any[]>((() => [])());
/**
* Free-text gate and normaliser, `(text: string) => string | boolean | null | undefined`, under `multiple` only. Called with the trimmed typed (or pasted) text before every free-text commit. Return the **string to store** (e.g. the bare address out of `Sam Roe <sam@x.test>`), `true` to store the text as typed, or a falsy value (`false` / `null` / `''`) to reject it — rejected text stays in the input. The same shape as Tags' `validate`. Setting it also turns on free-text commits (Enter with no highlighted option commits the typed text). A free-text commit appends the stored string to `value` (skipped when already present), clears the input, and emits `change` with `option: null` and the stored string as `text`.
* @example
* <rozie-combobox multiple [(value)]="to" [options]="contacts" [validate]="toAddress" />
*/
validate = input<((...args: any[]) => any) | null>(null);
/**
* Replaces the built-in paste split, `(text: string) => string[] | null`, under `multiple` only. Called with the clipboard text on every paste. Return the parts to commit — each is trimmed and passed through `validate`; accepted parts are committed and the rejected ones are inserted at the caret — or `null` to leave the paste to the browser untouched. Use it for syntax the delimiter split cannot know about, e.g. a quoted display name containing a comma (`"Roe, Sam" <sam@x.test>`). Setting it also turns on free-text commits.
* @example
* <rozie-combobox multiple [(value)]="to" [options]="contacts" [validate]="toAddress" [splitPaste]="splitAddresses" />
*/
splitPaste = input<((...args: any[]) => any) | null>(null);
/**
* Commit the typed text when the input loses focus, under `multiple` only, through `validate` like every other free-text commit: accepted text is committed and the input cleared, rejected text stays. A blur into a pinned host sub-surface (`pinOpen(true)`) does not commit. Setting it also turns on free-text commits. Default `false`.
*/
commitOnBlur = input<boolean>(false);
/**
* Tab picks the highlighted option while the popup is visible and an option is highlighted, keeping focus in the input. When nothing is picked, Tab moves focus normally. Default `false` (Tab always moves focus).
*/
selectOnTab = input<boolean>(false);
inputText = signal('');
isOpen = signal(false);
activeIndex = signal(-1);
rows = signal<any[]>([]);
windowVer = signal(0);
editVer = signal(0);
expandedGroups = signal({});
createdQuery = signal<any>(null);
pinned = signal(false);
autoId = signal('');
inputEl = viewChild<ElementRef<HTMLInputElement>>('inputEl');
__rozieRoot = viewChild<ElementRef<HTMLDivElement>>('__rozieRoot');
search = output<ComboboxSearchPayload>();
change = output<ComboboxChangePayload>();
create = output<ComboboxCreatePayload>();
@ContentChild('chip', { read: TemplateRef }) chipTpl?: TemplateRef<ChipCtx>;
@ContentChild('option', { read: TemplateRef }) optionTpl?: TemplateRef<OptionCtx>;
@ContentChild('empty', { read: TemplateRef }) emptyTpl?: TemplateRef<EmptyCtx>;
@ContentChild('create', { read: TemplateRef }) createTpl?: TemplateRef<CreateCtx>;
@ContentChild('groupHeading', { read: TemplateRef }) groupHeadingTpl?: TemplateRef<GroupHeadingCtx>;
@ContentChild('groupMore', { read: TemplateRef }) groupMoreTpl?: TemplateRef<GroupMoreCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
__rozieFills = contentChildren(RozieSlot, { descendants: true });
__rozieFillMap = computed(() => {
const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
for (const f of this.__rozieFills()) {
const k = f.rozieSlot();
if (k == null) continue;
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
map[k === '' ? 'defaultSlot' : k] = f.templateRef;
}
return map;
});
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = 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.inputText())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
if (this.expandedGroups() && Object.keys(this.expandedGroups()).length) this.expandedGroups.set({});
this.syncRows();
if (this.virtual() && this.virtualizer) {
this.virtualizer.setOptions(this.virtualizerOptions());
this.virtualizer._willUpdate();
this.windowVer.set(this.windowVer() + 1);
this.scheduleRemeasure();
}
})(); }); });
effect(() => { const __watchVal = (() => this.virtual())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } (() => {
if (this.expandedGroups() && Object.keys(this.expandedGroups()).length) this.expandedGroups.set({});
if (this.virtual()) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => this.buildVirtualizer());else setTimeout(() => this.buildVirtualizer(), 0);
} else {
this.teardownVirtualizer();
}
})(); }); });
}
ngAfterViewInit() {
if (!this.idBase()) this.autoId.set('rozie-combobox-' + this.nextAutoId());
this.syncQueryToValue();
this.syncRows();
this.didMount = true;
// Routes through the SAME buildVirtualizer() the virtual $watch calls below
// (VIRT-BUILD) — one construction site, so the mount path cannot drift from the flip
// path.
// Routes through the SAME buildVirtualizer() the virtual $watch calls below
// (VIRT-BUILD) — one construction site, so the mount path cannot drift from the flip
// path.
if (this.virtual()) this.buildVirtualizer();
}
// ══ 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`).
// - `idRoot()` — the host's id base (Listbox: the `id` prop, else the per-instance id it
// generates in $onMount); `optionId` below derives every option id from it.
// - `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.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 --------------------------------------------------
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;
};
// `idRoot()` is a HOST function (the host's id base: its id prop, else a generated
// per-instance id) so the option ids follow the host's auto-id fallback.
// ══ 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.
// - rowsWindowed(): boolean — is the ROW axis windowed. REQUIRED, no default — replaces every bare
// truthiness read of the host's windowing prop (D-05); `windowedRows()` /
// `padTop()` / `padBottom()` / `rowIsOutsideWindow()` below call it by
// convention exactly as they already call `pinnedEditIndex()`.
// - colsWindowed(): boolean — is the COLUMN axis windowed. REQUIRED, no default. `false` for every
// host until it defines the real column-axis mechanism (87-04+).
// - columnCount(): number — the leaf-column count the column virtualizer windows over. REQUIRED,
// no default.
// - columnSize(i: number): number — the authoritative width of absolute leaf column `i`, sourced
// from table-core's `getSize()` under D-06. REQUIRED, no default.
// - forcedColumns(): number[] — the D-10 OPTIONAL column-axis mirror of `pinnedEditIndex()`: the
// DataTable host unions pinned + active-cell + editing column indices into
// the column-window slice; listbox/combobox pass an empty array (host-
// provided, defaulting to `[]`).
// - colVirtualizer — the host's SECOND virtual-core instance, windowing the COLUMN axis
// (see the AXIS MECHANISM note below). Host-provided, defaulting to `null`.
// - autoMeasureOn(): boolean — the D-18 REQUIRED content-driven-estimate gate (Phase 87 87-07):
// data-table's real body reads `$props.autoMeasure === true`; listbox/
// combobox/command-palette return `false` so the accumulator branch
// estimateRowSize() gates on is dead code for them (D-20).
// - afterRowRemeasure — OPTIONAL host-owned mutable `let` (defaults to a no-op / undefined),
// assigned to refineRowEstimate() (below) by the host. The DataTable
// host's remeasureWindow() (virtualization.rzts) calls it AFTER its
// measureElement sweep so the fold + hysteresis re-feed run on every
// window commit. Routed through a mutable `let` rather than a direct
// call FROM virtualization.rzts INTO this file: a relative-partial CONST
// calling a bare-specifier-partial CONST is the exact forward-reference
// TDZ class remeasureColumnWindow()'s own DataTable.rozie comment
// documents for columnVirtualizerOptions() (inlineScriptPartials()
// groups the relative partial BEFORE the bare-specifier partial in the
// merged per-target output, regardless of source import order). A
// mutable `let` hoists to `useRef` on React and is excluded from a
// useCallback's dependency array, sidestepping the hazard entirely — the
// SAME mechanism `refreshRowModel` already relies on.
//
// AXIS MECHANISM (OQ1 / Assumption A1 — resolved from the installed source in 87-02;
// LANDED in 87-04: `columnVirtualizerOptions()` below IS the second, horizontal instance this
// note originally only documented). `horizontal` is a PER-INSTANCE field of `VirtualizerOptions`
// (`node_modules/@tanstack/virtual-core/dist/esm/index.d.ts:67`, installed version 3.17.1 per
// `package.json`), and every axis-sensitive internal read consults `instance.options.horizontal` —
// `measureElement`'s inlineSize/blockSize + offsetWidth/offsetHeight branch
// (`dist/esm/index.js:137,150`), `observeElementOffset`'s scrollLeft/scrollTop branch
// (`dist/esm/index.js:118-121`), `getMaxScrollOffset`'s scrollWidth/scrollHeight branch
// (`dist/esm/index.js:907-915`), and `scrollWithAdjustments`'s left/top branch
// (`dist/esm/index.js:152-161`). So ONE `Virtualizer` instance windows exactly ONE axis: the column
// axis needs its own SECOND, independent `Virtualizer` instance constructed with `horizontal: true`,
// sharing the SAME `getScrollElement()` (the `rdt-scroll` wrapper) the row instance already uses.
// Two options the row axis does not set that the column instance will need: `isRtl?: boolean`
// (data-table ships an RTL grid path) and `overscan?: number` (D-07 gives the column axis its own
// hardcoded constant, separate from the row axis's `overscan: 8` below).
//
// isRtl WIRING (gap-closure 87-09, LANDED — see `ensureColRtlWatch()`/`isColRtl()` below,
// immediately ahead of `columnVirtualizerOptions()`): data-table has no construction-time RTL
// signal (no `dir`/`rtl` prop), and `dir` can be set on `gridScrollEl` at ANY point relative to
// mount. `isRtl` is therefore computed LIVE via `getComputedStyle`, not baked in once.
// 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).
virtualItemKey = (i: any) => {
const src = this.windowSource();
return src && src[i] ? src[i].id : undefined;
};
// COL_OVERSCAN (D-07): the column axis's own hardcoded overscan constant, separate from the
// row axis's `overscan: 8` below. Columns are far wider than rows are tall, so one number
// cannot serve both axes; no prop is exposed because no consumer has asked to tune the row
// overscan across the four phases it has shipped. Unused until 87-04 constructs the second,
// horizontal Virtualizer instance (see the AXIS MECHANISM note above).
// ══ Phase 87 87-07 (D-15/D-18) — content-driven auto-measure: the shared engine's FIRST
// mutable top-level state. Hoisted to `useRef` PER-INSTANCE by the React emitter's
// hoistModuleLet — the SAME mechanism already load-bearing for `table`, `virtualizer`,
// `remeasurePending`, and `gridScrollEl` in the DataTable host (Task 1's confirmed
// precedent), so two DataTable instances on one page never share an accumulator
// (T-87-07-04). measuredRowTotal/measuredRowCount together give the running MEAN of every
// row folded in so far; lastFedRowEstimate is the estimate value most recently pushed into
// virtual-core (the hysteresis comparison baseline). ══
measuredRowTotal = 0;
measuredRowCount = 0;
// ══ Gap-closure 87-10 — windowVerBumpPending / bumpWindowVer(): coalesce EVERY $data.windowVer
// write behind a SINGLE microtask-deferred increment, regardless of how many callers request
// one within the same synchronous JS task. ══
//
// ROOT CAUSE (framework-agnostic; the Solid-specific symptom this closes only EXPOSES it) —
// confirmed by instrumenting the installed @tanstack/virtual-core@3.17.1 source directly
// (dist/esm/index.js), not by reasoning abstractly: virtual-core's resizeItem() calls
// `this.notify(false)` — synchronously invoking `virtualizerOptions().onChange` below — EVERY
// TIME a measured row's real size differs from its cached one (`delta !== 0`), independent of
// framework (dist/esm/index.js:836-874). remeasureWindow()'s CR-01 sweep
// (packages/ui/data-table/src/virtualization.rzts) measures EVERY currently-rendered `<tr>` in
// ONE for-loop BEFORE calling afterRowRemeasure() (refineRowEstimate() below) — so a single
// synchronous JS task (e.g. the very first measurement pass, which transitions N never-before-
// measured rows from the flat seed to their real heights) can fire onChange, and therefore an
// UNCOALESCED `$data.windowVer = $data.windowVer + 1`, MANY times in a row — well BEFORE
// refineRowEstimate()'s own fold-then-re-feed (which runs only AFTER that loop finishes) has
// folded those same measurements into the running mean or re-fed the converged estimate into
// virtual-core via setOptions(). A live trace of this exact sequence (instrumented resizeItem/
// getMeasurements calls, DataTableColumnVirtualDemo, autoMeasure on) showed Vue batching 3
// resizeItem calls before its ONE downstream re-render reads getMeasurements() — already
// reflecting the fully-folded, re-fed state — versus Solid re-running its padTop()/padBottom()
// effects SYNCHRONOUSLY and IMMEDIATELY on EVERY individual windowVer write (11 interleaved
// resize-then-immediate-recompute pairs, each recompute happening mid-sweep, before
// refineRowEstimate() had run even once). React/Vue/Svelte/Angular/Lit all batch their own
// reactivity to at least a microtask boundary, so their downstream reads land AFTER the whole
// synchronous burst (measurement sweep + fold + re-feed) completes — accidentally correct, not
// correct by construction. Solid does not auto-batch a signal write made from outside a
// Solid-owned event/effect context, so it is the one target where the mid-burst TORN read is
// externally observable. Because `setOptions()` + `_willUpdate()` alone do NOT invalidate
// virtual-core's own `getMeasurements()` memo (keyed on itemSizeCacheVersion /
// getMeasurementOptions() — never on the estimateSize FUNCTION reference itself; confirmed from
// the same installed source, dist/esm/index.js:585-587,624), Solid's LAST such mid-sweep
// recompute is also the LAST time getMeasurements() is ever invoked for that sweep once no
// further row happens to differ from its cache — so the DOM stays frozen on that stale,
// pre-fold/pre-re-feed snapshot indefinitely, even though the accumulator itself has already
// converged correctly (T-87-07's own confirmed finding).
//
// FIX: coalesce every requester of a windowVer bump — virtual-core's own onChange AND
// refineRowEstimate()'s explicit re-feed bump — behind ONE microtask-deferred write, the SAME
// idiom scheduleRemeasure() already uses in virtualization.rzts. This makes the render happen
// EXACTLY ONCE, strictly AFTER the entire synchronous burst (including refineRowEstimate()'s
// fold + re-feed) on EVERY target, by construction rather than by incidental host-framework
// batching. Scoped to the ROW axis only: colVirtualizer never calls resizeItem() at all (D-06 —
// column widths come from table-core's getSize() oracle, never measured from the DOM), so
// columnVirtualizerOptions()'s onChange cannot hit this burst class and is left untouched.
windowVerBumpPending = false;
bumpWindowVer = (): void => {
if (this.windowVerBumpPending) return;
this.windowVerBumpPending = true;
const flush = () => {
this.windowVerBumpPending = false;
this.windowVer.set(this.windowVer() + 1);
};
// Mirrors scheduleRemeasure()'s own defensive queueMicrotask-with-setTimeout-fallback
// (virtualization.rzts) for environments where queueMicrotask is unavailable.
if (typeof queueMicrotask !== 'undefined') queueMicrotask(flush);else setTimeout(flush, 0);
};
// ESTIMATE_REFEED_DELTA_PX (D-15): the hysteresis threshold gating a re-feed into
// virtual-core. Without it, a mean nudging by a fraction of a pixel on every fold would
// re-feed on every window commit — the T-87-07-01 DoS control, paired with virtual-core's
// own measureElement/resizeItem idempotence (see refineRowEstimate() below).
// estimateRowSize(i) (D-15/D-17): the estimateSize() resolver. MUST check !autoMeasureOn()
// FIRST so the off path touches zero accumulator state and returns $props.estimateRowHeight
// verbatim (D-17's byte-behavioral no-op). The zero-measurements case (first paint,
// regardless of autoMeasure) still returns the seed — the very first render has nothing
// measured yet either way (D-15).
estimateRowSize = (i: number): number => {
const __estimateRowHeight = this.estimateRowHeight();
if (!this.autoMeasureOn()) return __estimateRowHeight;
if (this.measuredRowCount === 0) return __estimateRowHeight;
return Math.round(this.measuredRowTotal / this.measuredRowCount);
};
// foldMeasuredRow(index, height): fold ONE measured row's height into the running-mean
// accumulator, UPDATING (not double-adding) an already-folded index (T-87-07-03).
// 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's windowVer write is routed through
// bumpWindowVer() (87-10) rather than a raw `$data.x = $data.x + 1` — resizeItem() can call
// this onChange MANY times in a single synchronous sweep (once per row whose real measured
// size differs from its cache, e.g. every never-before-measured row in the FIRST window),
// and coalescing those into one microtask-deferred write is what keeps every target's render
// landing strictly AFTER the whole sweep (see bumpWindowVer()'s own comment for the confirmed
// Solid-specific rendering gap this closes). The React emitter still lowers the underlying
// `$data.windowVer = $data.windowVer + 1` to functional setState — correct even deferred to a
// microtask, exactly as it was correct from a mount closure before.
virtualizerOptions = (): any => ({
count: this.windowSource().length,
getScrollElement: () => this.gridScrollEl,
estimateSize: (i: any) => this.estimateRowSize(i),
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: this.virtualItemKey,
onChange: () => {
this.bumpWindowVer();
// 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): 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: number): {
start: number;
size: number;
index: number;
end: number;
} | null => this.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 = () => {
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) {
// Rows OFF (Phase 87 D-04: this now includes the colsWindowed()-only path, since the
// wrapper template is entered whenever isWindowed(), not just rowsWindowed() — the row
// virtualizer is never constructed when only the column axis is windowed, D-04) → the FULL
// set, with a SYNTHETIC `vi.index` set to each row's array position (matching rowIndexOf's
// own `$data.rows.indexOf(row)` semantics exactly, since $data.rows IS windowSource()'s
// output here). Every windowed body binding reads wr.vi.index (data-row, aria-rowindex,
// colIndexOf, isEditing, the fill handle) — a bare `null` there is a hard crash the moment
// this branch is reached with the wrapper mounted, which colsWindowed()-only now does.
// Row-virtual ON but the virtualizer is not yet constructed (pre-$onMount first paint) →
// render NOTHING so the template never dereferences a not-yet-real `vi`; the rows appear on
// the first onChange after _didMount.
if (!this.rowsWindowed()) {
const rowList = __rows || [];
return rowList.map((r: any, i: any) => ({
vi: {
index: i
},
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;
};
// 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.
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.rowsWindowed() || !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.rowsWindowed() || !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: is full-model index `idx` present in the rendered virtual window?
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: any) => {
if (!this.rowsWindowed() || !this.virtualizer) return false;
const items = this.virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
// ══ Phase 87 87-04 — the column-axis analogs of windowedRows()/padTop()/padBottom()/
// rowIsOutsideWindow() above. The column axis has no "row-shaped" identity to carry alongside
// a VirtualItem (a column is not a full-model object the way a row is), so windowedColIndices()
// returns bare ABSOLUTE leaf-column indices; the template resolves each index back to a header/
// cell through the host's own header-group / visibleCellsFor lookups (D-08/D-09). ══
// windowedColIndices(): the ordered array of ABSOLUTE leaf-column indices to render.
virtualizer: any = null;
virtualizerCleanup: any = null;
gridScrollEl: any = null;
remeasurePending = false;
// Scroll-end pin state (see recordScrollEnd()): whether the USER left the view at the end,
// the option count at that moment, and the last scrollTop already accounted for.
scrollEndPinned: boolean = false;
scrollEndPinnedCount: number = -1;
scrollEndPinnedTop: number = -1;
// Non-reactive per-instance flag (Phase 86 R2, plan 86-03, Solid-only): true for
// the duration of an onFocus-triggered open transition (set before the isOpen
// write, cleared in the deferred microtask after). Lets onBlur distinguish a
// blur caused by Solid recreating the anchor's DOM mid-open (skip closing) from
// a genuine user-initiated blur (close normally). See onFocus/onBlur below.
openingInProgress = false;
// Non-reactive per-instance flag (combobox-virtual-reactivity phase): set true once
// $onMount has run; read by windowedView() below so the blank-frame fallback (D-4) only
// fires on a genuine RUNTIME flip — a virtual:true-at-mount (never-flipped) consumer's
// first paint stays byte-stable (windowedRows()'s own pre-mount `[]` still applies before
// didMount flips true). Mirrors the same write-in-$onMount/read-elsewhere holder class.
didMount = 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.
//
// Quick 260717-8zb dogfood: re-expressed on the `$memo(fn, keyFn)` primitive.
// `$memo` lowers (core, shared across all 6 targets) to a member-mutated
// fresh-object cache const + a wrapper function — EXACTLY this foCache shape,
// generalized. On React the emitted cache const is stabilized to
// `useMemo(() => ({…}), [])` by the EXISTING collectMutatedInstanceBinders/
// tryWrapMutatedInstanceUseMemo machinery (feedback_react_const_mutinstance_
// not_stabilized) — no per-target $memo code. On the 5 setup-once targets the
// top-level consts persist for the instance lifetime naturally.
//
// keyFn is the SUBSCRIBE-FIRST half (fine-grained Solid <For> / Svelte
// {#each}): it reads ALL FOUR reactive inputs UNCONDITIONALLY — $data.inputText
// even when disableFilter is true (mirrors windowing.rzts windowedRows
// void-touch discipline) and $props.groups even when $props.virtual (so a
// groups change while windowed still invalidates the cache once virtual
// toggles off) — evaluated BEFORE $memo's cache-hit check, so the r-for
// accessor subscribes to them on every eval. 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 / groups-ref) change.
//
// fn is the MISS path (unchanged from the hand-rolled foCache): run the
// filter, then (native option grouping, combobox-native-groups) a
// NON-VIRTUAL-ONLY stable re-partition into group-visual order, then map to
// wrapper rows.
filteredOptionsCache = {
keys: null as any[] | null,
val: null as any
};
filteredOptions = () => {
const __rozieMemoKey = (() => {
const __options = this.options();
const __inputText = this.inputText();
const opts = Array.isArray(__options) ? __options : [];
const df = !!this.disableFilter();
const q = String(__inputText == null ? '' : __inputText);
const groupsProp = this.groups();
return [opts, q, df, groupsProp];
})();
const __rozieMemoPrev = this.filteredOptionsCache.keys;
if (__rozieMemoPrev !== null && __rozieMemoPrev.length === __rozieMemoKey.length && __rozieMemoKey.every((v: any, i: any) => v === __rozieMemoPrev[i])) {
return this.filteredOptionsCache.val;
}
const __rozieMemoVal = (() => {
const __options = this.options();
const __inputText = this.inputText();
const opts = Array.isArray(__options) ? __options : [];
const df = !!this.disableFilter();
const q = String(__inputText == null ? '' : __inputText);
const groupsProp = this.groups();
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(this.labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
// Gated to !$props.virtual (groups×virtual is deferred/unsupported per design) AND to
// $props.groups being a NON-EMPTY array — an explicit author opt-in. This is deliberately
// NOT just "!$props.virtual" (groupOptions() would otherwise also fire whenever any raw
// option happens to carry a `.group` field, even with `groups` absent — a real collision
// discovered against command-palette's CommandItem.group, which is a PRE-EXISTING,
// unrelated per-row-badge field, not an opt-in to combobox's native grouping. The design's
// "Empty/absent `groups` ⇒ today's flat behavior, byte-identical" contract is about the
// `groups` PROP only — never inferred from incidental option shape.
if (!this.virtual() && Array.isArray(groupsProp) && groupsProp.length > 0) {
const partition = groupOptions(list, groupsProp, (o: any) => o && o.group != null ? String(o.group) : null);
list = partition.ordered;
}
// `_i` is assigned over the (now group-ordered) list, so the flat keyboard model
// (activeIndex/aria-activedescendant/nextEnabled) walks visual order unchanged.
// `group` carries the wrapper's normalized group id for groupBlocks() below.
return 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,
group: o && o.group != null ? String(o.group) : null
}));
})();
this.filteredOptionsCache.keys = __rozieMemoKey;
this.filteredOptionsCache.val = __rozieMemoVal;
return __rozieMemoVal;
};
// 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 = () => this.filteredOptions();
// windowedView() (combobox-virtual-reactivity, VIRT-FALLBACK): the combobox-side
// blank-frame fallback for the mid-flip frame. While `virtual` is on but the virtualizer
// has not yet (re)attached (didMount-gated, so the never-flipped virtual:true-at-mount
// first paint is untouched — windowedRows()'s own pre-mount `[]` still governs it),
// render the UN-WINDOWED full windowSource() slice mapped to the `{ vi: { index }, row }`
// shape the windowed template consumes (`wr.vi.index` resolves to the wrapper's own `_i`,
// since windowSource() IS the filtered/indexed list navRows()/activeIndex already walk).
// Once the virtualizer is built, delegates to windowedRows() UNCHANGED — byte-identical
// to today's steady windowed state. Entirely combobox-side: @rozie-ui/headless-core/
// windowing.rzts is untouched, preserving data-table's B13 A==B byte-identity + its
// empty-diff regen.
windowedView = () => {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}) — touch windowVer at the
// TOP, mirroring windowedRows()'s own subscribe-first discipline (windowing.rzts), so
// the accessor re-runs when buildVirtualizer()/kickWindow() bump windowVer once the
// virtualizer attaches — the transition OUT of this fallback and into windowedRows().
void this.windowVer();
if (this.virtual() && !this.virtualizer && this.didMount) {
return this.windowSource().map((row: any) => ({
vi: {
index: row._i
},
row
}));
}
return this.windowedRows();
};
// ---- native option grouping render helpers (combobox-native-groups) ---------------
// groupBlocks(): re-partition the ALREADY group-ordered filteredOptions() wrappers into
// CONTIGUOUS runs by wrapper.group (trivial + guarantees `_i` alignment, since `ordered`
// from groupOptions() is already group-contiguous). Attaches each run's `{ id, label }`
// from $props.groups (fallback label = the group id itself). Plain function — never
// $computed (mirrors filteredOptions()'s convention). Non-virtual only (isGrouped() below
// already gates the template branch that calls this).
groupBlocks = () => {
const __groups = this.groups();
const wrappers = this.filteredOptions();
const groupsProp = Array.isArray(__groups) ? __groups : [];
const labelFor = (gid: any) => {
const found = groupsProp.find((g: any) => g && g.id === gid);
return found ? found.label : gid;
};
const blocks = [];
let lastGid;
for (let i = 0; i < wrappers.length; i++) {
const w = wrappers[i];
if (i === 0 || w.group !== lastGid) {
blocks.push({
group: w.group == null ? null : {
id: w.group,
label: labelFor(w.group)
},
items: [w]
});
} else {
blocks[blocks.length - 1].items.push(w);
}
lastGid = w.group;
}
return blocks;
};
// isGrouped(): the grouped-vs-flat template branch selector. Grouping is active
// (non-virtual only) SOLELY when the author explicitly set a non-empty `groups` prop —
// deliberately NOT "OR any option carries a group" (a real collision discovered against
// command-palette's pre-existing CommandItem.group per-row-badge field; see the
// filteredOptions() comment above). Mirrors that same non-empty-`groups` gate exactly, so
// isGrouped() and the filteredOptions() partition never disagree about which branch is active.
isGrouped = () => !this.virtual() && Array.isArray(this.groups()) && this.groups().length > 0;
// ---- per-group result cap + expand-in-place "+N more" (combobox-group-cap) --------
// capNum(): coerce $props.groupCap to a whole, positive cap; anything else (NaN,
// negative, absent) degrades to 0 (uncapped). Plain function — never $computed.
capNum = () => {
const n = Number(this.groupCap());
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
};
// isCapped(): the capped-render branch selector. isGrouped() already gates non-
// virtual + non-empty `groups`, so the cap is automatically gated OUT of the
// virtual and ungrouped paths.
isCapped = () => this.isGrouped() && this.capNum() > 0;
// gkey(gid): normalize a group id (possibly null, for the leading ungrouped
// section) into an expandedGroups map key.
gkey = (gid: any) => gid == null ? '__ungrouped__' : String(gid);
// isExpanded(gid): whether the group has been expanded via its "+N more" row.
isExpanded = (gid: any) => !!(this.expandedGroups() && this.expandedGroups()[this.gkey(gid)]);
// expandGroup(gid): replace $data.expandedGroups IMMUTABLY (load-bearing for
// React re-render — feedback_react_const_mutinstance_not_stabilized / the
// graph-writeback immutability rule).
expandGroup = (gid: any) => {
this.expandedGroups.set(Object.assign({}, this.expandedGroups(), {
[this.gkey(gid)]: true
}));
};
// cappedBlocks(): the visible-block model for the capped render — groupBlocks()
// re-sliced to `capNum()` per group (unless expanded or non-overflowing), with a
// trailing "+N more" row appended to any still-capped block. Re-indexes `_i` as a
// running counter over the WHOLE visible+more sequence so option ids/aria-
// activedescendant stay contiguous and never disagree with navRows() below.
cappedBlocks = () => {
const blocks = this.groupBlocks();
const cap = this.capNum();
let running = 0;
const out = [];
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
const gid = blk.group ? blk.group.id : null;
const showAll = this.isExpanded(gid) || blk.items.length <= cap;
const visibleSrc = showAll ? blk.items : blk.items.slice(0, cap);
const items = [];
for (let vi = 0; vi < visibleSrc.length; vi++) {
items.push(Object.assign({}, visibleSrc[vi], {
_i: running
}));
running++;
}
let more: any = null;
if (!showAll) {
more = {
isMore: true,
group: gid,
hidden: blk.items.length - cap,
disabled: false,
_i: running,
expand: () => this.expandGroup(gid)
};
running++;
}
out.push({
group: blk.group,
items,
more
});
}
return out;
};
// ---- creatable mode (Phase 86 R3, D-17..D-20) ---------------------------
// normalizedQuery(): trimmed + lower-cased query — reuses the SAME case-fold
// filteredOptions() already applies above, but for an EXACT-EQUALITY
// comparison, never a substring search, and with NO Unicode normalization
// (R3 locked: a composition-form difference must NOT be treated as a match).
normalizedQuery = () => String(this.inputText() == null ? '' : this.inputText()).trim().toLowerCase();
// queryMatchesOption(nq): whether the (already-normalized) query is an exact,
// case-insensitive, trimmed match of some option's label.
queryMatchesOption = (nq: any) => {
const __options = this.options();
const opts = Array.isArray(__options) ? __options : [];
return opts.some((o: any) => String(this.labelOf(o)).trim().toLowerCase() === nq);
};
// isCreatableQuery(): the create-row visibility gate (also gates the `#empty`
// -> `#create` swap, D-19). `creatable` must be set, the normalized query
// must be non-empty (an empty/whitespace-only query never offers create —
// `#empty` keeps its job there), and no option's normalized label may equal
// it exactly.
isCreatableQuery = () => {
if (!this.creatable()) return false;
const nq = this.normalizedQuery();
if (!nq) return false;
return !this.queryMatchesOption(nq);
};
// createRowAt(baseCount): the synthetic, non-option `role="option"` create
// row (D-17) — mirrors the `groupMore` "+N more" row shape exactly (a real
// id, arrow-reachable, commits through the SAME selectOption() dispatch
// without writing the model). Each render branch passes ITS OWN flattened
// pre-create-row row count (`baseCount`) as the running index, exactly as
// `cappedBlocks()` already re-indexes `_i` across options + the more row —
// so ids / aria-activedescendant / navRows() can never disagree.
createRowAt = (baseCount: any) => ({
isCreate: true,
_i: baseCount,
disabled: false
});
// cappedRowCount(): the total navigable row count cappedBlocks() flattens to
// (visible items + more-rows, across every block) — the running index the
// capped branch's own create row (below) must continue from. Mirrors
// cappedBlocks()'s own `running` counter without re-deriving `_i` per item.
cappedRowCount = () => {
const blocks = this.cappedBlocks();
let n = 0;
for (let bi = 0; bi < blocks.length; bi++) {
n += blocks[bi].items.length;
if (blocks[bi].more) n++;
}
return n;
};
// navRows(): the SINGLE keyboard/aria source of truth. Returns the EXACT
// filteredOptions() reference when not capped and not creatable (byte-
// identical-off — untouched virtual/ungrouped keyboard path); flattens
// cappedBlocks() into visible items + more-rows, in order, when capped.
// Appends the create row, AFTER the full flattened visible(+more) sequence,
// whenever isCreatableQuery() — R3's locked "renders last, after all options
// and group sections" is a positional fact here, not a per-branch special case.
navRows = () => {
if (!this.isCapped()) {
const base = this.filteredOptions();
if (!this.isCreatableQuery()) return base;
return base.concat([this.createRowAt(base.length)]);
}
const out = [];
const blocks = this.cappedBlocks();
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
for (let ii = 0; ii < blk.items.length; ii++) out.push(blk.items[ii]);
if (blk.more) out.push(blk.more);
}
if (this.isCreatableQuery()) out.push(this.createRowAt(out.length));
return out;
};
// 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.
pinnedEditIndex = () => -1;
pinnedMeasurement = (pin: any) => null;
// D-05 windowing.rzts host-contract one-liner (Phase 87 87-02). rowsWindowed() preserves
// today's EXACT truthiness (byte-behavior-identical) — it is the REQUIRED symbol
// windowing.rzts calls in place of a bare `$props.virtual` read.
//
// GAP-CLOSURE 87-16 (WR-02): the column-axis host-contract symbols (`colVirtualizer`,
// `colsWindowed()`, `columnCount()`, `columnSize()`, `forcedColumns()`) that 87-02 added
// alongside this were REMOVED here — they were dead code shipped on a mistaken premise
// about the compiler's tree-shaking BFS. Combobox imports only `{ virtualItemKey,
// virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow }`
// from windowing.rzts; none of those functions' bodies reference the column-axis symbols
// (only `columnVirtualizerOptions()`/`windowedColIndices()`/`colPadLeft()`/`colPadRight()`/
// `colIsOutsideWindow()` do, and Combobox never imports any of those), so
// `inlineScriptPartials()`'s BFS never needed them to exist. See 87-REVIEW.md WR-02 /
// 87-16-SUMMARY.md for the verification trail.
rowsWindowed = () => !!this.virtual();
// autoMeasureOn() (Phase 87 87-07, D-18/D-20): the content-driven-estimate host-contract
// gate. Combobox never lights this branch — a permanent `false` keeps windowing.rzts's
// estimateRowSize()/refineRowEstimate() accumulator dead code here. RETAINED (unlike the
// column-axis symbols above): `virtualizerOptions()` — which Combobox DOES import and call
// — wires `estimateSize: (i) => estimateRowSize(i)`, and `estimateRowSize()` calls
// `autoMeasureOn()` as its first line. This one IS reachable through the import graph.
autoMeasureOn = (): boolean => false;
// Keep $data.rows === windowSource() so the windowing math indexes the live filtered set.
syncRows = () => {
this.rows.set(this.windowSource());
};
// SCROLL-END PIN (the data-table D-19 twin, shared shape with Listbox): keep a user who
// scrolled to the END of a variable-height list at the end while the options in view measure
// taller than their estimate. The view is judged on the DOM, and only at a move the USER
// made — a move is virtual-core's own when it still holds an unreconciled scroll adjustment
// (scrollAdjustments !== 0): its above-viewport compensation writes an ABSOLUTE scrollTop
// computed from its last-observed (stale) offset, so it pulls the view back up from the end
// and must neither clear the pin nor be mistaken for the user leaving the end. That position
// is remembered so the scroll event that later reports it is not read as a user move either.
// (Judging on virtual-core's MODEL, as the data-table host does, fails here: its total grows
// with every option measured in the ResizeObserver batch while its offset stays at the stale
// value, so the pin was cleared mid-batch — every target ended 10-126px short, measured.)
recordScrollEnd = () => {
if (!this.virtualizer || !this.gridScrollEl || this.virtualizer.scrollState) return;
const top: number = this.gridScrollEl.scrollTop;
if (top === this.scrollEndPinnedTop) return;
this.scrollEndPinnedTop = top;
if (this.virtualizer.scrollAdjustments !== 0) return;
// Only a list that actually overflows has an end to hold: while the window has not painted
// yet (or the list is closed), scrollHeight <= clientHeight reads as "at the end" and a pin
// recorded then would jump the freshly opened list to the bottom.
const sh = this.gridScrollEl.scrollHeight;
const ch = this.gridScrollEl.clientHeight;
this.scrollEndPinned = ch > 0 && sh - ch > 1 && sh - top - ch <= 1;
this.scrollEndPinnedCount = this.windowSource().length;
};
// Re-apply the pin after the framework has committed the window (called from the rAF pass):
// the real maximum is known only then. Not while a programmatic scroll (scrollToIndex) is in
// flight, and not when the option count changed since the user reached the end (a new query
// or appended options must not be auto-followed).
keepScrollEnd = () => {
if (!this.scrollEndPinned || !this.virtualizer || !this.gridScrollEl || this.virtualizer.scrollState) return;
if (this.windowSource().length !== this.scrollEndPinnedCount) return;
const maxTop: number = this.gridScrollEl.scrollHeight - this.gridScrollEl.clientHeight;
if (maxTop - this.gridScrollEl.scrollTop > 1) {
this.gridScrollEl.scrollTop = maxTop;
this.scrollEndPinnedTop = this.gridScrollEl.scrollTop;
}
};
// 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.
scheduleRemeasure = () => {
this.recordScrollEnd();
if (this.remeasurePending) return;
this.remeasurePending = true;
let ranMicro = false;
const microPass = () => {
this.remeasureWindow();
};
// N-05 (quick 260923-rrr): key the rAF pass on the OUTCOME. React and Angular commit the
// recycled window AFTER the first rAF, so one pass measured the OLD options and the new ones
// waited for virtual-core's 150ms scrolling-ended tick — with variable-height options the late
// above-viewport adjustment then moved the whole list (measured). Re-run next frame until the
// committed options cover the virtualizer's window, bounded (the data-table host twin).
let rafAttempts = 0;
const rafPass = () => {
const covered = this.remeasureWindow();
rafAttempts = rafAttempts + 1;
if (!covered && rafAttempts < 10 && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(rafPass);
return;
}
this.keepScrollEnd();
this.remeasurePending = false;
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.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.
remeasureWindow = () => {
if (!this.virtualizer || !this.gridScrollEl) return true;
if (this.virtualizer.scrollState) return true;
const els = this.gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]');
const rendered = new Set();
for (const el of els as any) {
this.virtualizer.measureElement(el);
rendered.add(el.getAttribute('data-index'));
}
// N-05: false while the framework has not yet committed the recycled window.
const items = this.virtualizer.getVirtualItems();
for (let i = 0; i < items.length; i++) {
if (!rendered.has(String(items[i].index))) return false;
}
return true;
};
// Keep the active option visible inside the popup. When windowing, route through the
// virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window scrolls
// into view (the windowed-arrow-nav seam). When NOT windowing, resolve the active
// option element directly (a within-own-shadow query, Lit-safe) and scrollIntoView it
// with 'nearest' block alignment — a plain long list taller than the popup's
// max-height must also keep the active option visible during arrow navigation.
scrollActiveIntoView = () => {
const __virtual = this.virtual();
const __activeIndex = this.activeIndex();
if (!__virtual && this.isOpen() && __activeIndex >= 0) {
const list = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rozie-combobox-list') : null;
const opt = list ? list.querySelector('#' + this.optId(__activeIndex)) : null;
if (opt) opt.scrollIntoView({
block: 'nearest'
});
return;
}
if (!__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();
};
// idRoot(): the id base — the `idBase` prop, else the per-instance id generated
// in $onMount (`autoId`), else the pre-mount fallback. Generated after mount (not
// during setup) so a server render and the hydrating client agree.
idRoot = () => this.idBase() || this.autoId() || 'rozie-combobox';
optId = (i: any) => this.idRoot() + '-opt-' + i;
listId = () => this.idRoot() + '-list';
// popupVisible() (hideEmpty, COMBOBOX-SPEC item 4): whether the popup is actually
// SHOWN — open AND (unless `hideEmpty`) something to render. With `hideEmpty` an
// open popup with no option rows AND no create row counts as hidden: the list
// branches do not render, aria-expanded reports false, and Escape is left to the
// host (B4). Without `hideEmpty` this is exactly `$data.isOpen` (byte-identical-off).
popupVisible = () => {
if (!this.isOpen()) return false;
if (!this.hideEmpty()) return true;
return this.navRows().length > 0;
};
// The active option's id for aria-activedescendant (null when none).
activeId = () => {
const __activeIndex = this.activeIndex();
const list = this.navRows();
if (this.popupVisible() && __activeIndex >= 0 && list[__activeIndex]) return this.optId(__activeIndex);
return null;
};
// activeOption() (handle verb, COMBOBOX-SPEC item 8): the highlighted RAW source
// option, or null (nothing highlighted, the popup is hidden, or the highlighted
// row is a synthetic "+N more" / create row).
activeOption: () => any = () => {
const list = this.navRows();
const ai = this.activeIndex();
if (!this.popupVisible() || ai < 0) return null;
const row = list[ai];
if (!row || row.isMore || row.isCreate) return null;
return row.option === undefined ? null : row.option;
};
// Next selectable index in `dir` (+1/-1), skipping disabled, clamped to ends.
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;
};
// ---- multi-select membership + effective-default helpers (Phase 86 R1) -----
// Ported from @rozie-ui/headless-core/listCore.rzts's select()/isSelected()
// algorithm (also shipped, verbatim, via @rozie-ui/listbox) — PORTED, not
// imported: combobox's own open/active/query state machine is deliberately
// host-local (see the header comment above), and listCore.rzts is also
// consumed by the release-ignored listbox family, so pulling this into the
// shared partial would put listbox's frozen leaves back in scope.
//
// selectedValues(): the current selection as a de-duplicated array, tolerant
// of a null/undefined model. De-duplicates the MODEL array itself (not just
// `options`) so a re-normalized selection never reports the same value twice
// even if the model ever ends up holding a duplicate.
selectedValues = () => {
const cur = this.value();
const arr = Array.isArray(cur) ? cur : [];
return Array.from(new Set(arr));
};
// isRowSelected(row): array membership under `multiple`, strict equality
// otherwise. Replaces every raw `opt.value === $props.value` / `wr.row.value
// === $props.value` template comparison (task 2) so all four render branches
// share exactly ONE membership check and can never disagree.
isRowSelected = (row: any) => {
if (!row) return false;
if (this.multiple()) return this.selectedValues().indexOf(row.value) !== -1;
return row.value === this.value();
};
// effectiveCloseOnSelect(): resolves the `closeOnSelect` sentinel (see the
// prop's own doc comment above for why the prop's default is `null`, not a
// literal `true`). Unset ⇒ `true` in single-select (today's default,
// unchanged), `false` under `multiple`; an explicit `true`/`false` from the
// consumer always wins in either mode. Every existing `closeOnSelect` read
// routes through this helper so the four render branches cannot disagree.
effectiveCloseOnSelect = () => {
const v = this.closeOnSelect();
if (v === true || v === false) return v;
return !this.multiple();
};
// chipsInline() (chipLayout, COMBOBOX-SPEC item 2): chips + input on one
// wrapping row — only meaningful under `multiple`.
chipsInline = () => !!this.multiple() && this.chipLayout() === 'inline';
// ---- chip rail (Phase 86 R1, plan 86-05, D-13/D-16/D-18) ---------------
// chipRows(): selectedValues() (already de-duplicated — see above) mapped to
// chip-rail display rows. Each row carries the raw source `option` when it is
// still present in `options` (mirroring how filteredOptions() attaches the raw
// option to every wrapper row), or a raw-value fallback label when the option
// has disappeared from an asynchronously swapped `options` array — the locked
// R1 concurrency edge: an orphan chip persists, labelled by its raw value,
// rather than vanishing. `value` array order IS chip display order (R1
// locked); selectedValues() already preserves it.
chipRows = () => {
const __options = this.options();
const opts = Array.isArray(__options) ? __options : [];
return this.selectedValues().map((v: any) => {
const found = opts.find((o: any) => this.valueOf$local(o) === v);
return found ? {
value: v,
label: this.labelOf(found),
option: found
} : {
value: v,
label: String(v),
option: null
};
});
};
// chipRemoveLabel(row): the aria-label naming what a chip's remove control removes.
chipRemoveLabel = (row: any) => 'Remove ' + String(row.label);
// removeChipValue(v) is defined AFTER selectOption() below (not here) — React's
// emitter derives each `useCallback`'s static dependency array from the
// helpers its body calls, and `removeChipValue` calls `selectOption`. Declaring
// it before `selectOption`'s own `const` would put `selectOption` in
// `removeChipValue`'s deps array ahead of its OWN initializer in the SAME
// module scope — a real same-render TDZ (`ReferenceError` at runtime on
// React, TS2448 "used before its declaration" at typecheck). Source order
// here IS emission order for these plain top-level consts, so
// `removeChipValue` must textually follow `selectOption`.
// ---- 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`). `effectiveCloseOnSelect()` gates the popup close.
selectOption = (opt: any) => {
const __multiple = this.multiple();
if (!opt) return;
if (opt.isMore) {
this.expandGroup(opt.group);
this.activeIndex.set(opt._i);
return;
}
if (opt.isCreate) {
// Read locals before any write (ROZ138 idiom).
const q = this.inputText();
const nq = this.normalizedQuery();
// The double-commit latch (D-17/D-20): a second commit of the SAME
// normalized query — whether a rapid double gesture, or the async
// round-trip window before the consumer's `options` update lands — is a
// no-op. An empty/whitespace normalized query never emits either (the
// row should not even be reachable then, since isCreatableQuery() gates
// it, but this guard is cheap insurance against a stale reference).
if (!nq || nq === this.createdQuery()) return;
this.createdQuery.set(nq);
this.create.emit({
query: q
});
// D-20: after `create` fires, local UI state behaves like a pick — the
// effective close-on-select applies, and the query clears in `multiple`
// mode (ready for the next entry) and is left alone in single mode (the
// consumer's async add flows back through the ordinary `value` watch).
// `value` itself is untouched — R3 locked.
if (this.effectiveCloseOnSelect()) this.isOpen.set(false);
if (__multiple) this.clearQuery(null);
this.activeIndex.set(-1);
return;
}
if (opt.disabled) return;
if (__multiple) {
// Capture whether the value was already present BEFORE the toggle — this
// local is what feeds the `selected` field on the `change` payload (D-15).
const cur = this.selectedValues();
const wasSelected = cur.indexOf(opt.value) !== -1;
// Fresh array on every commit — in-place mutation (.push/.splice) is
// silently dropped by the React/Solid/Lit/Angular change detectors.
const next = wasSelected ? cur.filter((v: any) => v !== opt.value) : [...cur, opt.value];
this.value.set(next), this.__rozieCvaOnChange(next);
// D-14: clear the query on pick under `multiple` (not the option's label)
// so Backspace-removes-last stays reachable immediately after a pick.
// `opt.isRemoval` (set only by removeChipValue() below) skips this —
// removing a chip is not a pick, and clobbering whatever the user was
// mid-typing in the search box is a separate, unrelated data loss.
if (!opt.isRemoval) this.clearQuery(null);
if (this.effectiveCloseOnSelect()) this.isOpen.set(false);
this.activeIndex.set(-1);
this.change.emit({
value: next,
option: opt.option,
selected: !wasSelected
});
return;
}
this.value.set(opt.value), this.__rozieCvaOnChange(opt.value);
this.inputText.set(String(opt.label));
if (this.effectiveCloseOnSelect()) this.isOpen.set(false);
this.activeIndex.set(-1);
// D-15: `selected` is additive and always `true` in single-select.
this.change.emit({
value: opt.value,
option: opt.option,
selected: true
});
};
// removeChipValue(v): routes chip removal through the EXACT SAME toggle path
// selectOption() uses for a re-select — a synthetic wrapper row is enough,
// since the `multiple` branch above only reads `opt.value`/`opt.option`/
// `opt.disabled`/`opt.isMore` — so removal and toggle-off can never diverge
// into different payload shapes. Declared here, after selectOption(), not
// alongside chipRows()/chipRemoveLabel() above — see the comment there.
removeChipValue = (v: any) => {
const __options = this.options();
const opts = Array.isArray(__options) ? __options : [];
const found = opts.find((o: any) => this.valueOf$local(o) === v);
// isRemoval: true tells selectOption()'s `multiple` branch this is a
// removal, not a pick — see the D-14 comment there.
this.selectOption({
value: v,
option: found || null,
isRemoval: true
});
};
// onChipRemovePointerDown() (quick-260903-0s1, E1 audit finding): the POINTER
// half of the chip remove control's split binding. Deliberately empty —
// the `.prevent` modifier this is bound to (mousedown) is its ENTIRE payload:
// preventDefault on mousedown suppresses the native focus shift, which is
// what keeps the input focused, keeps onBlur() from firing, and therefore
// keeps the popup open (the CR-02 hazard commit `d02a145ef` closed). The
// removal deliberately does NOT live here: preventDefault on mousedown does
// NOT suppress the click that follows it, so a handler bound to BOTH events
// would remove the chip twice per pointer press. See onChipRemoveActivate()
// below for where the removal actually happens.
onChipRemovePointerDown = () => {};
// onNativeInputChange() (release-0.8.0): the `.stop` on the input's native
// `change` is its whole payload — the native event bubbles out of the inner
// <input> on blur after an edit, and on Angular (no shadow boundary) a consumer
// `(change)` binding on <rozie-combobox> would receive that DOM Event as well as
// the component's own `change` output (the same collision popover's audit B6
// removed). Stopping it keeps `change` meaning only the component event.
onNativeInputChange = () => {};
// onChipRemoveActivate(v) (quick-260903-0s1, E1 audit finding): the CLICK half
// of the split binding — the actual removal. `click` is the one event every
// activation path produces: a real pointer press (mousedown+click), Enter or
// Space on the focused button (native <button> behavior fires `click`, never
// `keydown`-observable-as-such), AND a screen reader's synthesized activation
// (which emits `click` with no preceding `mousedown` at all — the E1 defect
// this fixes). Binding removal to `click` alone covers all three with exactly
// one removal per activation.
//
// Keyboard/AT activation puts DOM focus ON the button, which this removal
// then unmounts — without an explicit refocus, focus would fall to
// `document.body`. Restore it using the EXACT idiom onFocus() above already
// uses (proven on all six targets): a queued microtask that refocuses
// `$refs.inputEl` only when it exists and is not already `document.activeElement`.
// That activeElement guard is what makes this a strict no-op on the pointer
// path — a pointer press never moves focus off the input in the first place
// (onChipRemovePointerDown's preventDefault sees to that), so this refocus
// never re-enters onFocus() and never re-selects the in-progress query.
// $refs is safe here for the same reason it is safe everywhere else in this
// file: this is a post-mount event handler, not module-init code.
//
// `.stop` on the template's `@click` binding (real-browser VR finding,
// quick-260903-0s1): on Solid and Svelte specifically — the two targets whose
// reactivity applies a DOM mutation SYNCHRONOUSLY, inside the very handler
// that triggered it, rather than batched to a microtask like the other four
// — removing this chip's own `<li>` mid-click detaches the click event's
// `target` from the document BEFORE the event finishes bubbling. Popover's
// own document-level `@click.outside($refs.anchorEl,$refs.floatingEl)`
// dismiss listener (Popover.rozie) then evaluates `anchorEl.contains(target)`
// against the NOW-DETACHED target, which is unconditionally `false` for any
// detached node — misreading this internal removal as an outside click and
// closing the popup. `.stop` (stopPropagation) keeps this click from ever
// reaching that document listener, exactly like the sibling `@mousedown.stop`
// pattern command-palette's own action-menu-affordance row already uses to
// keep an inner gesture from bubbling into an ancestor's own listener.
onChipRemoveActivate = (v: any) => {
this.removeChipValue(v);
queueMicrotask(() => {
if (this.inputEl()?.nativeElement && document.activeElement !== this.inputEl()?.nativeElement) this.inputEl()!.nativeElement.focus();
});
};
// Reflect the externally-selected value into the input text. D-14: no-ops
// under `multiple` — there is no single label to mirror into the input once
// `value` holds an array, and the query is owned by chip-picking instead.
//
// quick-260903-0s1 (E2 audit finding): routed through the SAME valueOf()/
// labelOf() resolvers every other option read in this file uses
// (filteredOptions(), chipRows(), removeChipValue(), queryMatchesOption()) —
// this was the single site that still read the raw `.value`/`.label`
// properties directly. `optionValue`/`optionLabel` are documented public
// props, and the resolvers additionally carry the primitive-option fallback
// (`String(opt)` when `opt` has no `.label`) — bypassing them blanked the
// input on both the mount path ($onMount → syncQueryToValue()) and the
// external-value path ($watch(() => $props.value, ...) → syncQueryToValue()).
//
// The "not found" guard is on `opt` being neither `undefined` NOR `null`,
// deliberately not on truthiness: with primitive options the found entry IS
// the option, so a legitimate selection of an empty string or a zero would be
// discarded by a truthiness test and re-blank the input — reintroducing the
// bug in a new shape. `Array.prototype.find` returns `undefined` on a miss,
// so that is the correct miss test; the `null` check keeps a `null` option
// from rendering as the literal text "null".
syncQueryToValue = () => {
const __options = this.options();
if (this.multiple()) return;
const opts = Array.isArray(__options) ? __options : [];
const opt = opts.find((o: any) => this.valueOf$local(o) === this.value());
this.inputText.set(opt === undefined || opt === null ? '' : String(this.labelOf(opt)));
};
// ---- free-text commits (COMBOBOX-SPEC items 5-7, multiple only) --------
// delimiterList(): the `delimiters` prop normalized to an array.
delimiterList = () => Array.isArray(this.delimiters()) ? this.delimiters() : [];
// splitDelimiters(): the CHARACTER delimiters (everything but 'Enter'/'Tab') —
// the paste split characters.
splitDelimiters = () => this.delimiterList().filter((k: any) => k !== 'Enter' && k !== 'Tab');
// freeTextOn(): free-text commits are enabled under `multiple` when a delimiter
// list, a validate function, a splitPaste function or commitOnBlur is supplied.
freeTextOn = () => !!this.multiple() && (this.delimiterList().length > 0 || typeof this.validate() === 'function' || typeof this.splitPaste() === 'function' || !!this.commitOnBlur());
// storedText(t): the `validate` gate + normaliser (Tags' shape), for an already
// trimmed, non-empty `t`. Returns the string to store, or null when rejected:
// absent validate ⇒ t; a string return ⇒ that string ('' rejects); any other
// truthy return (`true`) ⇒ t; a falsy return ⇒ rejected.
storedText = (t: any) => {
const __validate = this.validate();
if (typeof __validate !== 'function') return t;
const r = __validate(t);
if (!r) return null;
return typeof r === 'string' ? r : t;
};
// commitTexts(texts): append every not-yet-present text to `value` (ONE fresh
// array, ONE model write) and emit one `change` per committed text, each with the
// running array as of that commit. Texts already present are skipped silently.
commitTexts = (texts: any) => {
let next = this.selectedValues();
const committed = [];
const snapshots = [];
for (let i = 0; i < texts.length; i++) {
const t = texts[i];
if (next.indexOf(t) !== -1) continue;
next = next.concat([t]);
committed.push(t);
snapshots.push(next);
}
if (committed.length > 0) this.value.set(next), this.__rozieCvaOnChange(next);
this.activeIndex.set(-1);
for (let i = 0; i < committed.length; i++) {
this.change.emit({
value: snapshots[i],
option: null,
selected: true,
text: committed[i]
});
}
};
// syncInputText(el, text): also write the LIVE input element. Angular compares a
// `[value]` binding against its last RENDERED value: fast typing followed by a
// commit in the same frame (before change detection rendered the typed text)
// leaves query '' === last-rendered '' — no DOM write, the typed text stays.
// Writing the element directly is idempotent on every other target.
syncInputText = (el: any, text: any) => {
if (el && typeof el.value === 'string' && el.value !== text) el.value = text;
};
// setTypedText(q, el): the input text changed to `q` — by typing (onInput) or by a
// paste Combobox handled itself (insertAtCaret). Re-arms the create latch, opens
// the list, highlights the first row and emits `search`, exactly as typing does.
setTypedText = (q: any, el: any) => {
this.inputText.set(q);
this.syncInputText(el, q);
// Any input change re-arms the double-commit latch (D-17/D-20) — a
// freshly-typed query is a new gesture, never a repeat of whatever was
// last created.
this.createdQuery.set(null);
this.isOpen.set(true);
this.activeIndex.set(0);
this.search.emit({
query: q
});
};
// clearQuery(el): Combobox clearing the input text ITSELF (a pick under
// `multiple`, a create under `multiple`, a free-text commit, clear()). Emits
// `search` with '' so a host tracking the query through `search` never goes
// stale — a free-text commit of an already-selected value fires no `change`,
// so this is the host's only signal. No emit when the text was already empty.
// The live element is consulted too: on React a commit in the same frame as the
// last keystroke still sees the pre-keystroke `inputText` in its closure.
clearQuery = (el: any) => {
const had = this.inputText() !== '' || !!(el && typeof el.value === 'string' && el.value !== '');
this.inputText.set('');
this.syncInputText(el, '');
if (had) this.search.emit({
query: ''
});
};
// insertAtCaret(el, text): insert `text` into the input at the caret, replacing
// the selection — what an ordinary paste does — and leave the caret after it.
insertAtCaret = (el: any, text: any) => {
const cur = el && typeof el.value === 'string' ? el.value : String(this.inputText());
const start = el && typeof el.selectionStart === 'number' ? el.selectionStart : cur.length;
const end = el && typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
const next = cur.slice(0, start) + text + cur.slice(end);
this.setTypedText(next, el);
const caret = start + text.length;
if (el && typeof el.setSelectionRange === 'function') el.setSelectionRange(caret, caret);
};
// commitFreeText(raw, el): trim → validate (normalise) → commit + clear the input.
// Returns true when the text was handled (committed, or already present ⇒ just
// cleared); false when empty or rejected — rejected text stays in the input.
commitFreeText = (raw: any, el: any) => {
const t = String(raw == null ? '' : raw).trim();
if (!t) return false;
const stored = this.storedText(t);
if (stored === null) return false;
this.clearQuery(el);
this.commitTexts([stored]);
return true;
};
// splitOnDelimiters(text): the built-in paste split — the clipboard text split on
// every CHARACTER delimiter, or null when it contains none (an ordinary paste).
splitOnDelimiters = (text: any) => {
const seps = this.splitDelimiters();
let hasSep = false;
for (let s = 0; s < seps.length; s++) {
if (text.indexOf(seps[s]) !== -1) hasSep = true;
}
if (!hasSep) return null;
let parts = [text];
for (let s = 0; s < seps.length; s++) {
const out = [];
for (let p = 0; p < parts.length; p++) {
const pieces = String(parts[p]).split(seps[s]);
for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
}
parts = out;
}
return parts;
};
// onPaste(e) (item 6): under free-text mode the clipboard text is split — by
// `splitPaste` when supplied, else on the character delimiters — and every
// non-empty trimmed part `validate` accepts is committed (the paste is
// preventDefault-ed). The rejected parts (joined by the first delimiter) are
// inserted at the caret, replacing the selection, as an ordinary paste would be,
// so text typed before the paste is kept. A split of null (splitPaste said "not
// mine", or no delimiter in the text) leaves the paste to the browser.
onPaste = (e: any) => {
const __splitPaste = this.splitPaste();
if (!this.freeTextOn()) return;
const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
// typeof checked inline (not via a local flag) so strict TS narrows the call.
const split = typeof __splitPaste === 'function' ? __splitPaste(text) : this.splitOnDelimiters(text);
if (!Array.isArray(split)) return;
if (e) e.preventDefault();
const accepted = [];
const rejected = [];
for (let i = 0; i < split.length; i++) {
const part = String(split[i] == null ? '' : split[i]).trim();
if (!part) continue;
const stored = this.storedText(part);
if (stored === null) rejected.push(part);else accepted.push(stored);
}
const seps = this.splitDelimiters();
const rest = rejected.join(seps.length > 0 ? seps[0] + ' ' : ' ');
if (rest) this.insertAtCaret(e ? e.target : null, rest);
this.commitTexts(accepted);
};
// ---- input + keyboard handlers -----------------------------------------
onInput = (e: any) => {
const q = e && e.target ? e.target.value : '';
this.setTypedText(q, null);
};
onFocus = (e: any) => {
// Phase 86 R2 (plan 86-03), Solid-only reentrancy guard: the input now
// renders inside the composed popover's SCOPED `#anchor` slot
// (`:open="$props.open"` among its params — see the <Popover> template
// comment for why the input moved there). On Solid, a named slot invocation
// with reactive scope params is a plain closure CALL re-run whenever any
// param changes (@rozie/core's documented, intentional Solid
// slot-reactivity design — not a bug to route around at the emitter level):
// the `isOpen` write below changes the `open` param this exact handler is
// responding to, which on Solid SYNCHRONOUSLY recreates the anchor's DOM
// subtree (Solid's JSX has no virtual-DOM diffing to preserve node identity
// across a closure re-invocation) — removing the just-focused `<input>`
// fires a NATIVE blur on it, mid-call-stack, before this function even
// returns. Without the guard below, that blur's own onBlur() would
// immediately set isOpen back to false, and the deferred re-focus further
// down would restart the SAME cycle on the fresh node — an infinite
// recreate/blur/close/refocus loop. `openingInProgress` (below) tells
// onBlur "this blur is a side effect of OUR OWN isOpen write, not the user
// moving focus away" so it can skip closing. The other 5 targets diff their
// scoped-slot re-render and keep the existing, already-focused node — no
// blur ever fires there, so the guard is a no-op for them.
// disableOpenOnFocus (item 3): focus alone never opens the list — typing
// (onInput) and ArrowDown/ArrowUp (onKeydown) still do.
if (this.disableOpenOnFocus()) {
if (e && e.target && e.target.select) e.target.select();
return;
}
this.openingInProgress = true;
this.isOpen.set(true);
// Cleared SYNCHRONOUSLY, immediately after the write — Solid's reactive
// cascade (if any) runs SYNCHRONOUSLY as part of that write, before this
// line executes, so the guard window covers exactly the recreate/blur
// cascade and nothing past it. A deferred (microtask) clear would leave a
// stale `true` window spanning an `await` boundary whenever the re-focus
// below re-enters onFocus, incorrectly suppressing a LATER, genuine blur.
this.openingInProgress = false;
if (e && e.target && e.target.select) e.target.select();
queueMicrotask(() => {
// Re-assert focus onto whatever node is CURRENT — after Solid's
// synchronous signal-write reactivity (if any) has already run and
// `$refs.inputEl` reflects the latest node — recovering focus if it was
// stranded on a since-removed one.
if (this.inputEl()?.nativeElement && document.activeElement !== this.inputEl()?.nativeElement) this.inputEl()!.nativeElement.focus();
});
};
// @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.
// While `pinned` (pinOpen(true)), early-return BEFORE the isOpen write — a host
// sub-surface (e.g. command-palette's action flyout) is holding focus and the
// popup must stay open until the host calls pinOpen(false) itself. While
// `openingInProgress` (Solid-only, see onFocus above), early-return too — this
// blur is a side effect of our OWN open-transition recreating the anchor's DOM,
// not the user moving focus elsewhere.
// commitOnBlur: leaving the field commits the typed text through validate (a blur
// into a pinned host sub-surface, or the Solid recreate blur, returned above).
onBlur = (e: any) => {
if (this.pinned()) return;
if (this.openingInProgress) return;
this.isOpen.set(false);
if (this.commitOnBlur() && this.freeTextOn()) {
const el = e ? e.target : null;
this.commitFreeText(el ? el.value : this.inputText(), el);
}
};
onKeydown = (e: any) => {
// B10: ignore every key while an IME composition is active — the Enter that
// confirms a composition must never pick, commit or navigate. Read through
// `nativeEvent` when present: React's synthetic keyboard event does not carry
// `isComposing` (every other target hands the native event straight through).
const ne = e && e.nativeEvent ? e.nativeEvent : e;
if (ne && (ne.isComposing || ne.keyCode === 229)) return;
const key = e ? e.key : '';
const list = this.navRows();
// 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();
const visible = this.popupVisible();
const liveText = e && e.target ? e.target.value : '';
const highlighted = wasOpen && ai >= 0 && list[ai] ? list[ai] : null;
// Character delimiters (item 5): commit the TYPED text — never the highlighted
// option. 'Enter' / 'Tab' entries are handled in their own branches below.
if (this.freeTextOn() && key !== 'Enter' && key !== 'Tab' && this.delimiterList().indexOf(key) !== -1) {
if (e) e.preventDefault();
this.commitFreeText(liveText, e ? e.target : null);
return;
}
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') {
// B9: Enter with Ctrl / Meta / Alt is left to the host (e.g. a send shortcut).
const modified = !!(e && (e.ctrlKey || e.metaKey || e.altKey));
if (!modified) {
if (highlighted) {
if (e) e.preventDefault();
this.selectOption(highlighted);
} else if (this.freeTextOn() && String(liveText).trim()) {
// Free-text mode (item 7): Enter with no highlighted option commits the
// typed text (rejected text stays in the input).
if (e) e.preventDefault();
this.commitFreeText(liveText, e ? e.target : null);
}
}
} else if (key === 'Tab') {
// selectOnTab (item 8): pick the highlighted option while the popup is
// visible; preventDefault ONLY when it picked. A 'Tab' delimiter commits the
// typed text when nothing was picked. Otherwise Tab moves focus normally.
if (this.selectOnTab() && visible && highlighted && !highlighted.disabled) {
if (e) e.preventDefault();
this.selectOption(highlighted);
} else if (this.freeTextOn() && this.delimiterList().indexOf('Tab') !== -1 && String(liveText).trim()) {
if (this.commitFreeText(liveText, e ? e.target : null) && e) e.preventDefault();
}
} else if (key === 'Escape') {
// B4: only consume Escape when the popup is actually VISIBLE.
if (visible) {
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));
}
} else if (key === 'Backspace') {
// Backspace-removes-last-chip (Tags.rozie precedent, Phase 86 R1 plan
// 86-05): guarded on `multiple` AND the LIVE input value being empty —
// read `e.target.value` directly (Tags' proven idiom), never the mirrored
// `$data.inputText`. A non-empty query falls through to normal text editing —
// nothing here removes a chip while there is text to delete.
if (this.multiple()) {
const liveValue = e && e.target ? e.target.value : '';
if (liveValue === '') {
const cur = this.selectedValues();
if (cur.length > 0) {
if (e) e.preventDefault();
this.removeChipValue(cur[cur.length - 1]);
}
}
}
}
// Keep the (new) active option in view — routes through the virtualizer when
// windowing, direct scrollIntoView otherwise.
this.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.
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);
}
};
// buildVirtualizer() (combobox-virtual-reactivity, VIRT-BUILD): the SINGLE virtualizer
// construction site — called from $onMount below (mount-time virtual:true) AND from the
// virtual $watch further down (a runtime false→true flip), so the mount path can never
// drift from the flip path. Guarded so a build queued (rAF-deferred by the $watch) that
// fires AFTER a flip-back is a no-op (rapid-flip idempotence), and so calling it twice
// never double-constructs.
buildVirtualizer = () => {
if (!this.virtual() || this.virtualizer) return;
// 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. 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.
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);
};
// teardownVirtualizer() (VIRT-TEARDOWN): runs the SAME per-instance cleanup fn
// $onUnmount invokes below, then nulls the instance state + bumps windowVer so the
// windowed template branch (still mounted while $props.virtual — CR-01) re-derives to
// the pre-construction fallback state instead of holding a stale virtualizer. This is
// the true→false ResizeObserver-leak fix: previously ONLY $onUnmount ever called
// virtualizerCleanup, so a runtime flip to non-virtual left the observer live.
teardownVirtualizer = () => {
if (this.virtualizerCleanup) this.virtualizerCleanup();
this.virtualizer = null;
this.virtualizerCleanup = null;
this.gridScrollEl = null;
this.windowVer.set(this.windowVer() + 1);
};
// nextAutoId(): a page-wide counter shared by every Rozie component instance. It
// lives on globalThis (read through Reflect, which type-checks in the plain-JS and
// the TS script alike) so separately bundled copies of a leaf never hand out the
// same id. The same four lines live in Combobox, Listbox and Popover.
nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
};
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. seedQuery(text) — imperative-only: write the input text
// (and therefore filteredOptions()'s filter) without touching the `value`
// model or selection state (a command-palette #2 levels/restore-on-pop
// prerequisite — repopulating the input on back-navigation is NOT a
// selection). pinOpen(v) — imperative-only: pin (or unpin) the popup open so
// onBlur() does not collapse it while a host sub-surface holds focus, AND
// (Phase 86-07 regression fix) so the composed Popover's OWN independent
// Escape/click-outside dismissal is vetoed too via `:disable-dismiss`
// (command-palette-sub-actions prerequisite). pinOpen(false) ONLY unpins — it
// does NOT itself close the popup or move focus; that is the host's job.
// Render-neutral when never called. All four are post-mount → $refs safe.
focus: () => void = () => this.inputEl()?.nativeElement?.focus();
clear: () => void = () => {
// Fresh empty array under `multiple` (never in-place mutation), null in
// single mode — mirrors selectOption()'s `{ value, option, selected }`
// shape; nothing is selected after a clear, so `selected` is `false`.
const empty = this.multiple() ? [] : null;
this.value.set(empty), this.__rozieCvaOnChange(empty);
this.clearQuery(null);
this.activeIndex.set(-1);
this.change.emit({
value: empty,
option: null,
selected: false
});
};
seedQuery: (text: string) => void = (text: any) => {
this.inputText.set(String(text == null ? '' : text));
};
pinOpen: (v: boolean) => void = (v: any) => {
this.pinned.set(!!v);
};
// query() — the current input text (what the last `search` reported).
query: () => string = () => this.inputText();
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 ChipCtx | OptionCtx | EmptyCtx | CreateCtx | GroupHeadingCtx | GroupMoreCtx {
return true;
}
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));
private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));
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 = [];
});
}
});
private _chip_ctx_2 = (row: any, idx: any) => ({ $implicit: { option: row.option, remove: () => this.onChipRemoveActivate(row.value), index: idx }, option: row.option, remove: () => this.onChipRemoveActivate(row.value), index: idx });
protected get __style() {
const __maxHeight = this.maxHeight();
return (this.popupVisible() ? '' : '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 { Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { Key } from '@solid-primitives/keyed';
import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
import Popover from '@rozie-ui/popover-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';
// ---- native option grouping (combobox-native-groups: src/internal/groupOptions.ts) ----
// The PURE stable-partition helper is a RUNTIME import (unlike listCore/windowing
// above, it is NOT a compile-time `.rzts` partial that dissolves at compile) —
// codegen's `copyInternal` vendors it verbatim into each leaf at
// `./internal/groupOptions`, mirroring command-palette's `scoreCommands.ts`.
import { groupOptions } from './internal/groupOptions';
// 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.
// The typed public surface (typed-surface P1; always TypeScript). `value` /
// `option` stay `any`: options are consumer-shaped objects (or primitives) the
// component never inspects beyond the label/value/disabled resolvers.
/** `search` payload — the current input text. */
export interface ComboboxSearchPayload {
query: string;
}
/** `change` payload — `option` is the raw source option (`null` for a clear or a free-text commit); `text` is set ONLY on free-text commits. */
export interface ComboboxChangePayload {
value: any;
option: any;
selected: boolean;
text?: string;
}
/** `create` payload — the (untrimmed) query the user asked to create. */
export interface ComboboxCreatePayload {
query: string;
}
/** An entry of the `groups` prop. */
export interface ComboboxGroup {
id: string;
label: string;
}
/** `chip` slot params — `remove()` removes the chip and refocuses the input. */
export interface ComboboxChipSlotCtx {
option: any;
remove: () => void;
index: number;
}
/** `option` slot params. */
export interface ComboboxOptionSlotCtx {
option: any;
index: number;
active: boolean;
selected: boolean;
disabled: boolean;
}
/** `empty` / `create` slot params. */
export interface ComboboxQuerySlotCtx {
query: string;
}
/** `groupHeading` slot params. */
export interface ComboboxGroupHeadingSlotCtx {
group: ComboboxGroup;
}
/** `groupMore` slot params. */
export interface ComboboxGroupMoreSlotCtx {
group: ComboboxGroup | null;
hidden: number;
expand: () => void;
}
__rozieInjectStyle('Combobox-9546115a', `.rozie-combobox[data-rozie-s-9546115a] {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input[data-rozie-s-9546115a] {
box-sizing: border-box;
/* Phase 86 R2 (plan 86-03): EXPLICIT width, not \`100%\`. The input now renders
inside popover's \`.rozie-popover-anchor\` (\`display: inline-block\`,
shrink-to-fit) rather than as a direct 100%-width child of \`.rozie-combobox\`
(\`width: var(--rozie-combobox-width, var(--rcb-width, 16rem))\`) — a percentage width here would
be circular against that shrink-to-fit ancestor (CSS 2.1 §10.3.3: an
unresolvable percentage against an auto-width parent degrades to the
intrinsic/auto size, NOT the control's real width), which is exactly the
bug this fixes: \`anchorEl\`'s measured rect must equal the input's real box
for Floating UI's positioning AND \`matchWidth\`'s reference width to be
correct. Reads the SAME \`--rozie-combobox-width\` token \`.rozie-combobox\`
itself uses, so the rendered pixel width is IDENTICAL to before this change
in the default (non-inline) case. \`.rozie-combobox--inline
.rozie-combobox-input\` below restores \`100%\` for the inline pass-through
path, where \`.rozie-combobox\` itself stretches to its container (unaffected
by this fix — \`disablePositioning\` skips anchor measurement entirely there). */
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
padding: var(--rozie-combobox-input-padding, var(--rcb-input-padding, 0.5rem 0.75rem));
font: inherit;
color: var(--rozie-combobox-color, var(--rcb-color, inherit));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
/*
Render-neutral bottom-divider token (260715-50l finding 3). A longhand
AFTER the \`border:\` shorthand above so it wins on the bottom side; the
fallback REPLICATES the shorthand's own bottom (border-width solid
border-color) so default rendering is byte-for-render unchanged. Lets a
consumer (e.g. command-palette) render a borderless-with-underline input
without touching the other three sides.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25))));
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input[data-rozie-s-9546115a]:focus {
/* Decoupled from --rozie-combobox-accent (finding 3) so a consumer can */
/* neutralize the focus BORDER without touching the selected-option accent. */
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
/*
Same underline token, focus-colored fallback — the longhand keeps
WINNING on the bottom side over the :focus border-color override above,
so a consumer-set divider survives both blurred and focused states.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc))));
}
.rozie-combobox--disabled[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, var(--rcb-disabled-opacity, 0.55));
background: var(--rozie-combobox-disabled-bg, var(--rcb-disabled-bg, rgba(0, 0, 0, 0.04)));
}
.rozie-combobox-list[data-rozie-s-9546115a] {
margin: 0;
padding: var(--rozie-combobox-list-padding, var(--rcb-list-padding, 0.25rem));
list-style: none;
max-height: var(--rozie-combobox-list-max-height, var(--rcb-list-max-height, 16rem));
overflow-y: auto;
background: var(--rozie-combobox-list-bg, var(--rcb-list-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-list-border-color, var(--rcb-list-border-color, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
box-shadow: var(--rozie-combobox-list-shadow, var(--rcb-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16)));
}
.rozie-combobox-option[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-option-padding, var(--rcb-option-padding, 0.4rem 0.6rem));
border-radius: var(--rozie-combobox-option-radius, var(--rcb-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, var(--rcb-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, var(--rcb-option-selected-weight, 600));
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, var(--rcb-option-selected-color, var(--rcb-accent, #0066cc))));
}
.rozie-combobox-option--disabled[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-empty[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-empty-padding, var(--rcb-empty-padding, 0.5rem 0.6rem));
color: var(--rozie-combobox-empty-color, var(--rcb-empty-color, rgba(0, 0, 0, 0.5)));
list-style: none;
}
.rozie-combobox-group[data-rozie-s-9546115a] {
list-style: none;
}
.rozie-combobox-group-heading[data-rozie-s-9546115a] {
/* Render-neutral section-separation token (260715-50l finding 4) — default */
/* 0 = unchanged; a consumer-set value separates the leading ungrouped */
/* block from the first group heading. */
margin-top: var(--rozie-combobox-group-heading-margin-top, var(--rcb-group-heading-margin-top, 0));
padding: var(--rozie-combobox-group-heading-padding, var(--rcb-group-heading-padding, 0.35rem 0.6rem 0.15rem));
font-size: var(--rozie-combobox-group-heading-size, var(--rcb-group-heading-size, 0.75rem));
font-weight: var(--rozie-combobox-group-heading-weight, var(--rcb-group-heading-weight, 600));
text-transform: var(--rozie-combobox-group-heading-transform, var(--rcb-group-heading-transform, uppercase));
letter-spacing: var(--rozie-combobox-group-heading-letter-spacing, var(--rcb-group-heading-letter-spacing, 0.03em));
color: var(--rozie-combobox-group-heading-color, var(--rcb-group-heading-color, rgba(0, 0, 0, 0.5)));
pointer-events: none;
user-select: none;
}
.rozie-combobox-more[data-rozie-s-9546115a] {
cursor: pointer;
color: var(--rozie-combobox-more-color, var(--rcb-more-color, rgba(0, 0, 0, 0.55)));
font-size: var(--rozie-combobox-more-size, var(--rcb-more-size, 0.875rem));
}
.rozie-combobox-create[data-rozie-s-9546115a] {
cursor: pointer;
color: var(--rozie-combobox-create-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
background: var(--rozie-combobox-create-bg, var(--rcb-create-bg, transparent));
}
.rozie-combobox-spacer[data-rozie-s-9546115a] { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox-list--virtual[data-rozie-s-9546115a] { overflow-anchor: none; }
.rozie-combobox-chips[data-rozie-s-9546115a] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
padding: var(--rozie-combobox-chips-padding, var(--rcb-chips-padding, 0.35rem 0.45rem 0 0.45rem));
margin: 0;
list-style: none;
}
.rozie-combobox-chip[data-rozie-s-9546115a] {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: var(--rozie-combobox-chip-padding, var(--rcb-chip-padding, 0.15rem 0.5rem));
font-size: var(--rozie-combobox-chip-size, var(--rcb-chip-size, 0.85rem));
color: var(--rozie-combobox-chip-color, inherit);
background: var(--rozie-combobox-chip-bg, var(--rcb-chip-bg, rgba(0, 102, 204, 0.12)));
border-radius: var(--rozie-combobox-chip-radius, var(--rcb-chip-radius, 0.375rem));
white-space: nowrap;
}
.rozie-combobox-chip__remove[data-rozie-s-9546115a] {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
height: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
padding: 0;
font: inherit;
line-height: 1;
color: var(--rozie-combobox-chip-remove-color, var(--rcb-chip-remove-color, currentColor));
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
transition: color 0.15s;
}
.rozie-combobox-chip__remove[data-rozie-s-9546115a]:hover:not([data-rozie-s-9546115a]:disabled) {
color: var(--rozie-combobox-chip-remove-hover-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
}
.rozie-combobox-chip__remove[data-rozie-s-9546115a]:disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-control[data-rozie-s-9546115a] {
display: contents;
}
.rozie-combobox--block[data-rozie-s-9546115a] {
display: block;
width: 100%;
container-type: inline-size;
}
.rozie-combobox--block[data-rozie-s-9546115a] .rozie-combobox-control[data-rozie-s-9546115a] {
display: block;
width: 100cqw;
}
.rozie-combobox--block[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
width: 100%;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] {
container-type: inline-size;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-control[data-rozie-s-9546115a] {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
width: 100cqw;
padding: var(--rozie-combobox-inline-padding, var(--rcb-inline-padding, 0.3rem 0.45rem));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-control[data-rozie-s-9546115a]:focus-within {
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-chips[data-rozie-s-9546115a] {
display: contents;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a],
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a]:focus {
flex: 1 1 var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
width: auto;
min-width: var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
padding: var(--rozie-combobox-inline-input-padding, var(--rcb-inline-input-padding, 0.2rem 0.25rem));
background: transparent;
border: none;
box-shadow: 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\` dropped (plan 86-03): \`.rozie-combobox-list\` carries no
absolute positioning to undo anymore — that geometry lives on popover's
\`.rozie-popover-floating\`, and \`:disable-positioning="$props.inline"\`
(D-09) already renders it as a static pass-through via popover's own
\`.rozie-popover-floating--static\` rule. */
margin-top: var(--rozie-combobox-list-gap, var(--rcb-list-gap, 0.25rem));
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-combobox--inline[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
width: 100%;
}`);
interface ChipSlotCtx { option: any; remove: () => void; index: number; }
interface OptionSlotCtx { option: any; index: number; active: boolean; selected: boolean; disabled: boolean; }
interface EmptySlotCtx { query: string; }
interface CreateSlotCtx { query: string; }
interface GroupHeadingSlotCtx { group: ComboboxGroup; }
interface GroupMoreSlotCtx { group: ComboboxGroup | null; hidden: number; expand: () => void; }
interface ComboboxProps extends Omit<import('solid-js').ComponentProps<'div'>, 'value' | 'defaultValue' | 'onValueChange' | 'options' | 'placeholder' | 'disabled' | 'disableFilter' | 'ariaLabel' | 'idBase' | 'inline' | 'closeOnSelect' | 'multiple' | 'creatable' | 'optionLabel' | 'optionValue' | 'optionDisabled' | 'virtual' | 'estimateRowHeight' | 'maxHeight' | 'groups' | 'groupCap' | 'placement' | 'offset' | 'disableFlip' | 'disableShift' | 'block' | 'chipLayout' | 'disableOpenOnFocus' | 'hideEmpty' | 'delimiters' | 'validate' | 'splitPaste' | 'commitOnBlur' | 'selectOnTab' | 'onSearch' | 'onChange' | 'onCreate' | 'chipSlot' | 'optionSlot' | 'emptySlot' | 'createSlot' | 'groupHeadingSlot' | 'groupMoreSlot' | 'slots' | 'ref' | 'children' | 'innerHTML' | 'innerText' | 'textContent'> {
/**
* 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 value={country()} onValueChange={setCountry} options={countries} />
*/
value?: (unknown) | null;
defaultValue?: (unknown) | null;
onValueChange?: (value: (unknown) | null) => void;
/**
* The option list — `[{ value, label, disabled?, group? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, an optional `disabled` flag makes an option non-selectable, and an optional `group` string buckets the option under a matching entry of the `groups` prop (or a first-appearance fallback section) when grouping is active.
*/
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, option and popup elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`, the listbox id is `idBase + "-list"`. Leave it empty (the default) and each instance generates a unique id base after mount (`rozie-combobox-<n>`); set it when you need stable, predictable ids. 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. Unset (default) resolves through `effectiveCloseOnSelect()`: `true` in single-select (today's default behavior) and `false` in `multiple` mode, where closing after every chip pick would make multi-select unusable. Pass an explicit `true` or `false` to override in either mode.
*/
closeOnSelect?: (boolean) | null;
/**
* `value` widens to hold an **array** of selected values and remains the sole `model: true` prop, so the Angular `ControlValueAccessor` is preserved (a second model would forfeit it — `ROZ125`). Re-selecting an already-selected option toggles it off. Default `false` is byte-identical to single-select.
*/
multiple?: boolean;
/**
* When the user commits text matching no option (case-insensitive, trimmed, exact label equality — no Unicode normalization applied), combobox emits `create` with the query and writes NOTHING to `value` — the consumer adds the option to `options` and updates the model itself. Composes with `multiple`. Turning this on replaces the `#empty` fill with the `#create` row whenever the query is creatable (non-empty, no exact match); `#empty` still renders for an empty or whitespace-only query. Default `false` is byte-identical to today.
*/
creatable?: 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;
/**
* Ordered section list `[{ id, label }]` setting group order + heading text. Options are partitioned by their optional `group?` string; groups present on options but absent here fall back to first-appearance order after the listed ones. Empty/absent ⇒ flat, ungrouped rendering (default).
*/
groups?: any[];
/**
* Cap each native section group to its first `groupCap` results, adding a keyboard-reachable '+N more' row that expands that group IN PLACE when activated. `0`/absent = uncapped (default). Only applies to the non-virtual grouped render (`groups` non-empty); ignored when `virtual` is on.
*/
groupCap?: number;
/**
* Floating UI placement of the popup relative to the control, forwarded to the composed `@rozie-ui/popover` leaf — one of `top`/`right`/`bottom`/`left`, each optionally suffixed `-start`/`-end`. Default `"bottom-start"` matches the pre-Phase-86 static popup alignment (flush with the control's left edge). Ignored when `inline` is set.
*/
placement?: string;
/**
* Gap in pixels between the control and the popup, forwarded to the composed `@rozie-ui/popover` leaf. Default `4` preserves the pre-Phase-86 resting gap (`--rozie-combobox-list-gap`). Ignored when `inline` is set.
*/
offset?: number;
/**
* Disable the popup's Floating UI `flip` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup flips above the control when it would overflow the viewport below; set this to keep it pinned to `placement` regardless. Ignored when `inline` is set.
*/
disableFlip?: boolean;
/**
* Disable the popup's Floating UI `shift` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup shifts to stay within the viewport; set this to keep it strictly aligned to the control. Ignored when `inline` is set.
*/
disableShift?: boolean;
/**
* Fill the container: the root becomes `display: block; width: 100%`, the control (chips + input) stretches to that width, and the width-matched popup follows. Adds the `rozie-combobox--block` modifier class on the root. Default `false` keeps the fixed `--rozie-combobox-width` sizing.
*/
block?: boolean;
/**
* Chip rail layout under `multiple`: `'stacked'` (default) renders the chips above the input; `'inline'` puts the chips and the input on ONE wrapping row (the Tags layout), with the input taking the remaining width (`flex: 1`, never narrower than `--rozie-combobox-inline-input-min-width`). Only meaningful with `multiple`.
*/
chipLayout?: string;
/**
* Do not open the list when the input gains focus. Typing and ArrowDown / ArrowUp still open it. Default `false` opens on focus.
*/
disableOpenOnFocus?: boolean;
/**
* Show nothing instead of the empty state: when there are no option rows and no create row, the popup is not shown, the input reports `aria-expanded="false"`, and Escape is left to the host (not `preventDefault`ed). This is the supported way to render no popup at all; filling the `empty` slot with nothing still renders the fallback on most targets.
*/
hideEmpty?: boolean;
/**
* Keys that commit the **typed text** as a value (matched against the key event's `key`), under `multiple` only — a delimiter never picks the highlighted option. Character entries (e.g. `[',', ';']`) also split pasted text: a paste containing a delimiter is split on them, every non-empty trimmed part that `validate` accepts is committed, and the rejected parts are inserted at the caret (replacing the selection) like an ordinary paste, so text typed before the paste is kept. Use `splitPaste` to replace this split. `'Enter'` and `'Tab'` are allowed; Enter then commits the typed text only when no option is highlighted. A non-empty list (or `validate`, `splitPaste` or `commitOnBlur`) turns on free-text commits, so Enter with no highlighted option commits the typed text too. Default `[]` (off).
* @example
* <Combobox multiple value={to()} onValueChange={setTo} options={contacts} delimiters={delims} />
*/
delimiters?: any[];
/**
* Free-text gate and normaliser, `(text: string) => string | boolean | null | undefined`, under `multiple` only. Called with the trimmed typed (or pasted) text before every free-text commit. Return the **string to store** (e.g. the bare address out of `Sam Roe <sam@x.test>`), `true` to store the text as typed, or a falsy value (`false` / `null` / `''`) to reject it — rejected text stays in the input. The same shape as Tags' `validate`. Setting it also turns on free-text commits (Enter with no highlighted option commits the typed text). A free-text commit appends the stored string to `value` (skipped when already present), clears the input, and emits `change` with `option: null` and the stored string as `text`.
* @example
* <Combobox multiple value={to()} onValueChange={setTo} options={contacts} validate={toAddress} />
*/
validate?: ((...args: any[]) => any) | null;
/**
* Replaces the built-in paste split, `(text: string) => string[] | null`, under `multiple` only. Called with the clipboard text on every paste. Return the parts to commit — each is trimmed and passed through `validate`; accepted parts are committed and the rejected ones are inserted at the caret — or `null` to leave the paste to the browser untouched. Use it for syntax the delimiter split cannot know about, e.g. a quoted display name containing a comma (`"Roe, Sam" <sam@x.test>`). Setting it also turns on free-text commits.
* @example
* <Combobox multiple value={to()} onValueChange={setTo} options={contacts} validate={toAddress} splitPaste={splitAddresses} />
*/
splitPaste?: ((...args: any[]) => any) | null;
/**
* Commit the typed text when the input loses focus, under `multiple` only, through `validate` like every other free-text commit: accepted text is committed and the input cleared, rejected text stays. A blur into a pinned host sub-surface (`pinOpen(true)`) does not commit. Setting it also turns on free-text commits. Default `false`.
*/
commitOnBlur?: boolean;
/**
* Tab picks the highlighted option while the popup is visible and an option is highlighted, keeping focus in the input. When nothing is picked, Tab moves focus normally. Default `false` (Tab always moves focus).
*/
selectOnTab?: boolean;
onSearch?: (payload: ComboboxSearchPayload) => void;
onChange?: (payload: ComboboxChangePayload) => void;
onCreate?: (payload: ComboboxCreatePayload) => void;
chipSlot?: (ctx: ChipSlotCtx) => JSX.Element;
optionSlot?: (ctx: OptionSlotCtx) => JSX.Element;
emptySlot?: (ctx: EmptySlotCtx) => JSX.Element;
createSlot?: (ctx: CreateSlotCtx) => JSX.Element;
groupHeadingSlot?: (ctx: GroupHeadingSlotCtx) => JSX.Element;
groupMoreSlot?: (ctx: GroupMoreSlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: ComboboxHandle) => void;
}
export interface ComboboxHandle {
focus: () => void;
clear: () => void;
seedQuery: (text: string) => void;
pinOpen: (v: boolean) => void;
activeOption: () => any;
query: () => string;
}
export default function Combobox(_props: ComboboxProps): JSX.Element {
const _merged = mergeProps({ options: (() => [])() as any[], placeholder: '', disabled: false, disableFilter: false, ariaLabel: null, idBase: '', inline: false, closeOnSelect: null, multiple: false, creatable: false, optionLabel: null, optionValue: null, optionDisabled: null, virtual: false, estimateRowHeight: 36, maxHeight: '', groups: (() => [])() as any[], groupCap: 0, placement: 'bottom-start', offset: 4, disableFlip: false, disableShift: false, block: false, chipLayout: 'stacked', disableOpenOnFocus: false, hideEmpty: false, delimiters: (() => [])() as any[], validate: null, splitPaste: null, commitOnBlur: false, selectOnTab: false }, _props);
const [local, attrs] = splitProps(_merged, ['value', 'options', 'placeholder', 'disabled', 'disableFilter', 'ariaLabel', 'idBase', 'inline', 'closeOnSelect', 'multiple', 'creatable', 'optionLabel', 'optionValue', 'optionDisabled', 'virtual', 'estimateRowHeight', 'maxHeight', 'groups', 'groupCap', 'placement', 'offset', 'disableFlip', 'disableShift', 'block', 'chipLayout', 'disableOpenOnFocus', 'hideEmpty', 'delimiters', 'validate', 'splitPaste', 'commitOnBlur', 'selectOnTab', 'ref', 'onSearch', 'onChange', 'onCreate']);
onMount(() => { local.ref?.({ focus, clear, seedQuery, pinOpen, activeOption, query }); });
const [value, setValue] = createControllableSignal<unknown>(_props as unknown as Record<string, unknown>, 'value', null);
const [inputText, setInputText] = 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);
const [expandedGroups, setExpandedGroups] = createSignal<Record<string, any>>({});
const [createdQuery, setCreatedQuery] = createSignal<any>(null);
const [pinned, setPinned] = createSignal(false);
const [autoId, setAutoId] = createSignal('');
onMount(() => {
if (!local.idBase) setAutoId('rozie-combobox-' + nextAutoId());
syncQueryToValue();
syncRows();
didMount = true;
// Routes through the SAME buildVirtualizer() the virtual $watch calls below
// (VIRT-BUILD) — one construction site, so the mount path cannot drift from the flip
// path.
if (local.virtual) buildVirtualizer();
});
onCleanup(() => {
if (virtualizerCleanup) virtualizerCleanup();
});
createEffect(on(() => (() => value())(), (v) => untrack(() => (() => {
syncQueryToValue();
})()), { defer: true }));
createEffect(on(() => (() => (local.options ? local.options.length : 0) + '|' + inputText())(), (v) => untrack(() => (() => {
if (expandedGroups() && Object.keys(expandedGroups()).length) setExpandedGroups({});
syncRows();
if (local.virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
setWindowVer(windowVer() + 1);
scheduleRemeasure();
}
})()), { defer: true }));
createEffect(on(() => (() => local.virtual)(), (v) => untrack(() => (() => {
if (expandedGroups() && Object.keys(expandedGroups()).length) setExpandedGroups({});
if (local.virtual) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => buildVirtualizer());else setTimeout(() => buildVirtualizer(), 0);
} else {
teardownVirtualizer();
}
})()), { 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`).
// - `idRoot()` — the host's id base (Listbox: the `id` prop, else the per-instance id it
// generates in $onMount); `optionId` below derives every option id from it.
// - `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.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;
}
// `idRoot()` is a HOST function (the host's id base: its id prop, else a generated
// per-instance id) so the option ids follow the host's auto-id fallback.
// ══ 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.
// - rowsWindowed(): boolean — is the ROW axis windowed. REQUIRED, no default — replaces every bare
// truthiness read of the host's windowing prop (D-05); `windowedRows()` /
// `padTop()` / `padBottom()` / `rowIsOutsideWindow()` below call it by
// convention exactly as they already call `pinnedEditIndex()`.
// - colsWindowed(): boolean — is the COLUMN axis windowed. REQUIRED, no default. `false` for every
// host until it defines the real column-axis mechanism (87-04+).
// - columnCount(): number — the leaf-column count the column virtualizer windows over. REQUIRED,
// no default.
// - columnSize(i: number): number — the authoritative width of absolute leaf column `i`, sourced
// from table-core's `getSize()` under D-06. REQUIRED, no default.
// - forcedColumns(): number[] — the D-10 OPTIONAL column-axis mirror of `pinnedEditIndex()`: the
// DataTable host unions pinned + active-cell + editing column indices into
// the column-window slice; listbox/combobox pass an empty array (host-
// provided, defaulting to `[]`).
// - colVirtualizer — the host's SECOND virtual-core instance, windowing the COLUMN axis
// (see the AXIS MECHANISM note below). Host-provided, defaulting to `null`.
// - autoMeasureOn(): boolean — the D-18 REQUIRED content-driven-estimate gate (Phase 87 87-07):
// data-table's real body reads `$props.autoMeasure === true`; listbox/
// combobox/command-palette return `false` so the accumulator branch
// estimateRowSize() gates on is dead code for them (D-20).
// - afterRowRemeasure — OPTIONAL host-owned mutable `let` (defaults to a no-op / undefined),
// assigned to refineRowEstimate() (below) by the host. The DataTable
// host's remeasureWindow() (virtualization.rzts) calls it AFTER its
// measureElement sweep so the fold + hysteresis re-feed run on every
// window commit. Routed through a mutable `let` rather than a direct
// call FROM virtualization.rzts INTO this file: a relative-partial CONST
// calling a bare-specifier-partial CONST is the exact forward-reference
// TDZ class remeasureColumnWindow()'s own DataTable.rozie comment
// documents for columnVirtualizerOptions() (inlineScriptPartials()
// groups the relative partial BEFORE the bare-specifier partial in the
// merged per-target output, regardless of source import order). A
// mutable `let` hoists to `useRef` on React and is excluded from a
// useCallback's dependency array, sidestepping the hazard entirely — the
// SAME mechanism `refreshRowModel` already relies on.
//
// AXIS MECHANISM (OQ1 / Assumption A1 — resolved from the installed source in 87-02;
// LANDED in 87-04: `columnVirtualizerOptions()` below IS the second, horizontal instance this
// note originally only documented). `horizontal` is a PER-INSTANCE field of `VirtualizerOptions`
// (`node_modules/@tanstack/virtual-core/dist/esm/index.d.ts:67`, installed version 3.17.1 per
// `package.json`), and every axis-sensitive internal read consults `instance.options.horizontal` —
// `measureElement`'s inlineSize/blockSize + offsetWidth/offsetHeight branch
// (`dist/esm/index.js:137,150`), `observeElementOffset`'s scrollLeft/scrollTop branch
// (`dist/esm/index.js:118-121`), `getMaxScrollOffset`'s scrollWidth/scrollHeight branch
// (`dist/esm/index.js:907-915`), and `scrollWithAdjustments`'s left/top branch
// (`dist/esm/index.js:152-161`). So ONE `Virtualizer` instance windows exactly ONE axis: the column
// axis needs its own SECOND, independent `Virtualizer` instance constructed with `horizontal: true`,
// sharing the SAME `getScrollElement()` (the `rdt-scroll` wrapper) the row instance already uses.
// Two options the row axis does not set that the column instance will need: `isRtl?: boolean`
// (data-table ships an RTL grid path) and `overscan?: number` (D-07 gives the column axis its own
// hardcoded constant, separate from the row axis's `overscan: 8` below).
//
// isRtl WIRING (gap-closure 87-09, LANDED — see `ensureColRtlWatch()`/`isColRtl()` below,
// immediately ahead of `columnVirtualizerOptions()`): data-table has no construction-time RTL
// signal (no `dir`/`rtl` prop), and `dir` can be set on `gridScrollEl` at ANY point relative to
// mount. `isRtl` is therefore computed LIVE via `getComputedStyle`, not baked in once.
// 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;
}
// COL_OVERSCAN (D-07): the column axis's own hardcoded overscan constant, separate from the
// row axis's `overscan: 8` below. Columns are far wider than rows are tall, so one number
// cannot serve both axes; no prop is exposed because no consumer has asked to tune the row
// overscan across the four phases it has shipped. Unused until 87-04 constructs the second,
// horizontal Virtualizer instance (see the AXIS MECHANISM note above).
// ══ Phase 87 87-07 (D-15/D-18) — content-driven auto-measure: the shared engine's FIRST
// mutable top-level state. Hoisted to `useRef` PER-INSTANCE by the React emitter's
// hoistModuleLet — the SAME mechanism already load-bearing for `table`, `virtualizer`,
// `remeasurePending`, and `gridScrollEl` in the DataTable host (Task 1's confirmed
// precedent), so two DataTable instances on one page never share an accumulator
// (T-87-07-04). measuredRowTotal/measuredRowCount together give the running MEAN of every
// row folded in so far; lastFedRowEstimate is the estimate value most recently pushed into
// virtual-core (the hysteresis comparison baseline). ══
let measuredRowTotal = 0;
let measuredRowCount = 0;
// ══ Gap-closure 87-10 — windowVerBumpPending / bumpWindowVer(): coalesce EVERY $data.windowVer
// write behind a SINGLE microtask-deferred increment, regardless of how many callers request
// one within the same synchronous JS task. ══
//
// ROOT CAUSE (framework-agnostic; the Solid-specific symptom this closes only EXPOSES it) —
// confirmed by instrumenting the installed @tanstack/virtual-core@3.17.1 source directly
// (dist/esm/index.js), not by reasoning abstractly: virtual-core's resizeItem() calls
// `this.notify(false)` — synchronously invoking `virtualizerOptions().onChange` below — EVERY
// TIME a measured row's real size differs from its cached one (`delta !== 0`), independent of
// framework (dist/esm/index.js:836-874). remeasureWindow()'s CR-01 sweep
// (packages/ui/data-table/src/virtualization.rzts) measures EVERY currently-rendered `<tr>` in
// ONE for-loop BEFORE calling afterRowRemeasure() (refineRowEstimate() below) — so a single
// synchronous JS task (e.g. the very first measurement pass, which transitions N never-before-
// measured rows from the flat seed to their real heights) can fire onChange, and therefore an
// UNCOALESCED `$data.windowVer = $data.windowVer + 1`, MANY times in a row — well BEFORE
// refineRowEstimate()'s own fold-then-re-feed (which runs only AFTER that loop finishes) has
// folded those same measurements into the running mean or re-fed the converged estimate into
// virtual-core via setOptions(). A live trace of this exact sequence (instrumented resizeItem/
// getMeasurements calls, DataTableColumnVirtualDemo, autoMeasure on) showed Vue batching 3
// resizeItem calls before its ONE downstream re-render reads getMeasurements() — already
// reflecting the fully-folded, re-fed state — versus Solid re-running its padTop()/padBottom()
// effects SYNCHRONOUSLY and IMMEDIATELY on EVERY individual windowVer write (11 interleaved
// resize-then-immediate-recompute pairs, each recompute happening mid-sweep, before
// refineRowEstimate() had run even once). React/Vue/Svelte/Angular/Lit all batch their own
// reactivity to at least a microtask boundary, so their downstream reads land AFTER the whole
// synchronous burst (measurement sweep + fold + re-feed) completes — accidentally correct, not
// correct by construction. Solid does not auto-batch a signal write made from outside a
// Solid-owned event/effect context, so it is the one target where the mid-burst TORN read is
// externally observable. Because `setOptions()` + `_willUpdate()` alone do NOT invalidate
// virtual-core's own `getMeasurements()` memo (keyed on itemSizeCacheVersion /
// getMeasurementOptions() — never on the estimateSize FUNCTION reference itself; confirmed from
// the same installed source, dist/esm/index.js:585-587,624), Solid's LAST such mid-sweep
// recompute is also the LAST time getMeasurements() is ever invoked for that sweep once no
// further row happens to differ from its cache — so the DOM stays frozen on that stale,
// pre-fold/pre-re-feed snapshot indefinitely, even though the accumulator itself has already
// converged correctly (T-87-07's own confirmed finding).
//
// FIX: coalesce every requester of a windowVer bump — virtual-core's own onChange AND
// refineRowEstimate()'s explicit re-feed bump — behind ONE microtask-deferred write, the SAME
// idiom scheduleRemeasure() already uses in virtualization.rzts. This makes the render happen
// EXACTLY ONCE, strictly AFTER the entire synchronous burst (including refineRowEstimate()'s
// fold + re-feed) on EVERY target, by construction rather than by incidental host-framework
// batching. Scoped to the ROW axis only: colVirtualizer never calls resizeItem() at all (D-06 —
// column widths come from table-core's getSize() oracle, never measured from the DOM), so
// columnVirtualizerOptions()'s onChange cannot hit this burst class and is left untouched.
let windowVerBumpPending = false;
function bumpWindowVer(): void {
if (windowVerBumpPending) return;
windowVerBumpPending = true;
const flush = () => {
windowVerBumpPending = false;
setWindowVer(windowVer() + 1);
};
// Mirrors scheduleRemeasure()'s own defensive queueMicrotask-with-setTimeout-fallback
// (virtualization.rzts) for environments where queueMicrotask is unavailable.
if (typeof queueMicrotask !== 'undefined') queueMicrotask(flush);else setTimeout(flush, 0);
}
// ESTIMATE_REFEED_DELTA_PX (D-15): the hysteresis threshold gating a re-feed into
// virtual-core. Without it, a mean nudging by a fraction of a pixel on every fold would
// re-feed on every window commit — the T-87-07-01 DoS control, paired with virtual-core's
// own measureElement/resizeItem idempotence (see refineRowEstimate() below).
// estimateRowSize(i) (D-15/D-17): the estimateSize() resolver. MUST check !autoMeasureOn()
// FIRST so the off path touches zero accumulator state and returns $props.estimateRowHeight
// verbatim (D-17's byte-behavioral no-op). The zero-measurements case (first paint,
// regardless of autoMeasure) still returns the seed — the very first render has nothing
// measured yet either way (D-15).
function estimateRowSize(i: number): number {
if (!autoMeasureOn()) return local.estimateRowHeight;
if (measuredRowCount === 0) return local.estimateRowHeight;
return Math.round(measuredRowTotal / measuredRowCount);
}
// foldMeasuredRow(index, height): fold ONE measured row's height into the running-mean
// accumulator, UPDATING (not double-adding) an already-folded index (T-87-07-03).
// 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's windowVer write is routed through
// bumpWindowVer() (87-10) rather than a raw `$data.x = $data.x + 1` — resizeItem() can call
// this onChange MANY times in a single synchronous sweep (once per row whose real measured
// size differs from its cache, e.g. every never-before-measured row in the FIRST window),
// and coalescing those into one microtask-deferred write is what keeps every target's render
// landing strictly AFTER the whole sweep (see bumpWindowVer()'s own comment for the confirmed
// Solid-specific rendering gap this closes). The React emitter still lowers the underlying
// `$data.windowVer = $data.windowVer + 1` to functional setState — correct even deferred to a
// microtask, exactly as it was correct from a mount closure before.
function virtualizerOptions(): any {
return {
count: windowSource().length,
getScrollElement: () => gridScrollEl,
estimateSize: (i: any) => estimateRowSize(i),
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
bumpWindowVer();
// 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) {
// Rows OFF (Phase 87 D-04: this now includes the colsWindowed()-only path, since the
// wrapper template is entered whenever isWindowed(), not just rowsWindowed() — the row
// virtualizer is never constructed when only the column axis is windowed, D-04) → the FULL
// set, with a SYNTHETIC `vi.index` set to each row's array position (matching rowIndexOf's
// own `$data.rows.indexOf(row)` semantics exactly, since $data.rows IS windowSource()'s
// output here). Every windowed body binding reads wr.vi.index (data-row, aria-rowindex,
// colIndexOf, isEditing, the fill handle) — a bare `null` there is a hard crash the moment
// this branch is reached with the wrapper mounted, which colsWindowed()-only now does.
// Row-virtual ON but the virtualizer is not yet constructed (pre-$onMount first paint) →
// render NOTHING so the template never dereferences a not-yet-real `vi`; the rows appear on
// the first onChange after _didMount.
if (!rowsWindowed()) {
const rowList = rows() || [];
return rowList.map((r: any, i: any) => ({
vi: {
index: i
},
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 (!rowsWindowed() || !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 (!rowsWindowed() || !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 (!rowsWindowed() || !virtualizer) return false;
const items = virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
}
// ══ Phase 87 87-04 — the column-axis analogs of windowedRows()/padTop()/padBottom()/
// rowIsOutsideWindow() above. The column axis has no "row-shaped" identity to carry alongside
// a VirtualItem (a column is not a full-model object the way a row is), so windowedColIndices()
// returns bare ABSOLUTE leaf-column indices; the template resolves each index back to a header/
// cell through the host's own header-group / visibleCellsFor lookups (D-08/D-09). ══
// windowedColIndices(): the ordered array of ABSOLUTE leaf-column indices to render.
// 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;
// Scroll-end pin state (see recordScrollEnd()): whether the USER left the view at the end,
// the option count at that moment, and the last scrollTop already accounted for.
let scrollEndPinned: boolean = false;
let scrollEndPinnedCount: number = -1;
let scrollEndPinnedTop: number = -1;
// Non-reactive per-instance flag (Phase 86 R2, plan 86-03, Solid-only): true for
// the duration of an onFocus-triggered open transition (set before the isOpen
// write, cleared in the deferred microtask after). Lets onBlur distinguish a
// blur caused by Solid recreating the anchor's DOM mid-open (skip closing) from
// a genuine user-initiated blur (close normally). See onFocus/onBlur below.
let openingInProgress = false;
// Non-reactive per-instance flag (combobox-virtual-reactivity phase): set true once
// $onMount has run; read by windowedView() below so the blank-frame fallback (D-4) only
// fires on a genuine RUNTIME flip — a virtual:true-at-mount (never-flipped) consumer's
// first paint stays byte-stable (windowedRows()'s own pre-mount `[]` still applies before
// didMount flips true). Mirrors the same write-in-$onMount/read-elsewhere holder class.
let didMount = 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.
//
// Quick 260717-8zb dogfood: re-expressed on the `$memo(fn, keyFn)` primitive.
// `$memo` lowers (core, shared across all 6 targets) to a member-mutated
// fresh-object cache const + a wrapper function — EXACTLY this foCache shape,
// generalized. On React the emitted cache const is stabilized to
// `useMemo(() => ({…}), [])` by the EXISTING collectMutatedInstanceBinders/
// tryWrapMutatedInstanceUseMemo machinery (feedback_react_const_mutinstance_
// not_stabilized) — no per-target $memo code. On the 5 setup-once targets the
// top-level consts persist for the instance lifetime naturally.
//
// keyFn is the SUBSCRIBE-FIRST half (fine-grained Solid <For> / Svelte
// {#each}): it reads ALL FOUR reactive inputs UNCONDITIONALLY — $data.inputText
// even when disableFilter is true (mirrors windowing.rzts windowedRows
// void-touch discipline) and $props.groups even when $props.virtual (so a
// groups change while windowed still invalidates the cache once virtual
// toggles off) — evaluated BEFORE $memo's cache-hit check, so the r-for
// accessor subscribes to them on every eval. 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 / groups-ref) change.
//
// fn is the MISS path (unchanged from the hand-rolled foCache): run the
// filter, then (native option grouping, combobox-native-groups) a
// NON-VIRTUAL-ONLY stable re-partition into group-visual order, then map to
// wrapper rows.
const filteredOptionsCache = {
keys: null as any[] | null,
val: null as any
};
function filteredOptions() {
const __rozieMemoKey = (() => {
const opts = Array.isArray(local.options) ? local.options : [];
const df = !!local.disableFilter;
const q = String(inputText() == null ? '' : inputText());
const groupsProp = local.groups;
return [opts, q, df, groupsProp];
})();
const __rozieMemoPrev = filteredOptionsCache.keys;
if (__rozieMemoPrev !== null && __rozieMemoPrev.length === __rozieMemoKey.length && __rozieMemoKey.every((v: any, i: any) => v === __rozieMemoPrev[i])) {
return filteredOptionsCache.val;
}
const __rozieMemoVal = (() => {
const opts = Array.isArray(local.options) ? local.options : [];
const df = !!local.disableFilter;
const q = String(inputText() == null ? '' : inputText());
const groupsProp = local.groups;
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
// Gated to !$props.virtual (groups×virtual is deferred/unsupported per design) AND to
// $props.groups being a NON-EMPTY array — an explicit author opt-in. This is deliberately
// NOT just "!$props.virtual" (groupOptions() would otherwise also fire whenever any raw
// option happens to carry a `.group` field, even with `groups` absent — a real collision
// discovered against command-palette's CommandItem.group, which is a PRE-EXISTING,
// unrelated per-row-badge field, not an opt-in to combobox's native grouping. The design's
// "Empty/absent `groups` ⇒ today's flat behavior, byte-identical" contract is about the
// `groups` PROP only — never inferred from incidental option shape.
if (!local.virtual && Array.isArray(groupsProp) && groupsProp.length > 0) {
const partition = groupOptions(list, groupsProp, (o: any) => o && o.group != null ? String(o.group) : null);
list = partition.ordered;
}
// `_i` is assigned over the (now group-ordered) list, so the flat keyboard model
// (activeIndex/aria-activedescendant/nextEnabled) walks visual order unchanged.
// `group` carries the wrapper's normalized group id for groupBlocks() below.
return list.map((o: any, i: any) => ({
value: valueOf(o),
label: labelOf(o),
disabled: disabledOf(o),
_i: i,
id: valueOf(o),
option: o,
group: o && o.group != null ? String(o.group) : null
}));
})();
filteredOptionsCache.keys = __rozieMemoKey;
filteredOptionsCache.val = __rozieMemoVal;
return __rozieMemoVal;
}
// 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();
}
// windowedView() (combobox-virtual-reactivity, VIRT-FALLBACK): the combobox-side
// blank-frame fallback for the mid-flip frame. While `virtual` is on but the virtualizer
// has not yet (re)attached (didMount-gated, so the never-flipped virtual:true-at-mount
// first paint is untouched — windowedRows()'s own pre-mount `[]` still governs it),
// render the UN-WINDOWED full windowSource() slice mapped to the `{ vi: { index }, row }`
// shape the windowed template consumes (`wr.vi.index` resolves to the wrapper's own `_i`,
// since windowSource() IS the filtered/indexed list navRows()/activeIndex already walk).
// Once the virtualizer is built, delegates to windowedRows() UNCHANGED — byte-identical
// to today's steady windowed state. Entirely combobox-side: @rozie-ui/headless-core/
// windowing.rzts is untouched, preserving data-table's B13 A==B byte-identity + its
// empty-diff regen.
function windowedView() {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}) — touch windowVer at the
// TOP, mirroring windowedRows()'s own subscribe-first discipline (windowing.rzts), so
// the accessor re-runs when buildVirtualizer()/kickWindow() bump windowVer once the
// virtualizer attaches — the transition OUT of this fallback and into windowedRows().
void windowVer();
if (local.virtual && !virtualizer && didMount) {
return windowSource().map((row: any) => ({
vi: {
index: row._i
},
row
}));
}
return windowedRows();
}
// ---- native option grouping render helpers (combobox-native-groups) ---------------
// groupBlocks(): re-partition the ALREADY group-ordered filteredOptions() wrappers into
// CONTIGUOUS runs by wrapper.group (trivial + guarantees `_i` alignment, since `ordered`
// from groupOptions() is already group-contiguous). Attaches each run's `{ id, label }`
// from $props.groups (fallback label = the group id itself). Plain function — never
// $computed (mirrors filteredOptions()'s convention). Non-virtual only (isGrouped() below
// already gates the template branch that calls this).
function groupBlocks() {
const wrappers = filteredOptions();
const groupsProp = Array.isArray(local.groups) ? local.groups : [];
const labelFor = (gid: any) => {
const found = groupsProp.find((g: any) => g && g.id === gid);
return found ? found.label : gid;
};
const blocks = [];
let lastGid;
for (let i = 0; i < wrappers.length; i++) {
const w = wrappers[i];
if (i === 0 || w.group !== lastGid) {
blocks.push({
group: w.group == null ? null : {
id: w.group,
label: labelFor(w.group)
},
items: [w]
});
} else {
blocks[blocks.length - 1].items.push(w);
}
lastGid = w.group;
}
return blocks;
}
// isGrouped(): the grouped-vs-flat template branch selector. Grouping is active
// (non-virtual only) SOLELY when the author explicitly set a non-empty `groups` prop —
// deliberately NOT "OR any option carries a group" (a real collision discovered against
// command-palette's pre-existing CommandItem.group per-row-badge field; see the
// filteredOptions() comment above). Mirrors that same non-empty-`groups` gate exactly, so
// isGrouped() and the filteredOptions() partition never disagree about which branch is active.
function isGrouped() {
return !local.virtual && Array.isArray(local.groups) && local.groups.length > 0;
}
// ---- per-group result cap + expand-in-place "+N more" (combobox-group-cap) --------
// capNum(): coerce $props.groupCap to a whole, positive cap; anything else (NaN,
// negative, absent) degrades to 0 (uncapped). Plain function — never $computed.
function capNum() {
const n = Number(local.groupCap);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
}
// isCapped(): the capped-render branch selector. isGrouped() already gates non-
// virtual + non-empty `groups`, so the cap is automatically gated OUT of the
// virtual and ungrouped paths.
function isCapped() {
return isGrouped() && capNum() > 0;
}
// gkey(gid): normalize a group id (possibly null, for the leading ungrouped
// section) into an expandedGroups map key.
function gkey(gid: any) {
return gid == null ? '__ungrouped__' : String(gid);
}
// isExpanded(gid): whether the group has been expanded via its "+N more" row.
function isExpanded(gid: any) {
return !!(expandedGroups() && expandedGroups()[gkey(gid)]);
}
// expandGroup(gid): replace $data.expandedGroups IMMUTABLY (load-bearing for
// React re-render — feedback_react_const_mutinstance_not_stabilized / the
// graph-writeback immutability rule).
function expandGroup(gid: any) {
setExpandedGroups(Object.assign({}, expandedGroups(), {
[gkey(gid)]: true
}));
}
// cappedBlocks(): the visible-block model for the capped render — groupBlocks()
// re-sliced to `capNum()` per group (unless expanded or non-overflowing), with a
// trailing "+N more" row appended to any still-capped block. Re-indexes `_i` as a
// running counter over the WHOLE visible+more sequence so option ids/aria-
// activedescendant stay contiguous and never disagree with navRows() below.
function cappedBlocks() {
const blocks = groupBlocks();
const cap = capNum();
let running = 0;
const out = [];
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
const gid = blk.group ? blk.group.id : null;
const showAll = isExpanded(gid) || blk.items.length <= cap;
const visibleSrc = showAll ? blk.items : blk.items.slice(0, cap);
const items = [];
for (let vi = 0; vi < visibleSrc.length; vi++) {
items.push(Object.assign({}, visibleSrc[vi], {
_i: running
}));
running++;
}
let more: any = null;
if (!showAll) {
more = {
isMore: true,
group: gid,
hidden: blk.items.length - cap,
disabled: false,
_i: running,
expand: () => expandGroup(gid)
};
running++;
}
out.push({
group: blk.group,
items,
more
});
}
return out;
}
// ---- creatable mode (Phase 86 R3, D-17..D-20) ---------------------------
// normalizedQuery(): trimmed + lower-cased query — reuses the SAME case-fold
// filteredOptions() already applies above, but for an EXACT-EQUALITY
// comparison, never a substring search, and with NO Unicode normalization
// (R3 locked: a composition-form difference must NOT be treated as a match).
function normalizedQuery() {
return String(inputText() == null ? '' : inputText()).trim().toLowerCase();
}
// queryMatchesOption(nq): whether the (already-normalized) query is an exact,
// case-insensitive, trimmed match of some option's label.
function queryMatchesOption(nq: any) {
const opts = Array.isArray(local.options) ? local.options : [];
return opts.some((o: any) => String(labelOf(o)).trim().toLowerCase() === nq);
}
// isCreatableQuery(): the create-row visibility gate (also gates the `#empty`
// -> `#create` swap, D-19). `creatable` must be set, the normalized query
// must be non-empty (an empty/whitespace-only query never offers create —
// `#empty` keeps its job there), and no option's normalized label may equal
// it exactly.
function isCreatableQuery() {
if (!local.creatable) return false;
const nq = normalizedQuery();
if (!nq) return false;
return !queryMatchesOption(nq);
}
// createRowAt(baseCount): the synthetic, non-option `role="option"` create
// row (D-17) — mirrors the `groupMore` "+N more" row shape exactly (a real
// id, arrow-reachable, commits through the SAME selectOption() dispatch
// without writing the model). Each render branch passes ITS OWN flattened
// pre-create-row row count (`baseCount`) as the running index, exactly as
// `cappedBlocks()` already re-indexes `_i` across options + the more row —
// so ids / aria-activedescendant / navRows() can never disagree.
function createRowAt(baseCount: any) {
return {
isCreate: true,
_i: baseCount,
disabled: false
};
}
// cappedRowCount(): the total navigable row count cappedBlocks() flattens to
// (visible items + more-rows, across every block) — the running index the
// capped branch's own create row (below) must continue from. Mirrors
// cappedBlocks()'s own `running` counter without re-deriving `_i` per item.
function cappedRowCount() {
const blocks = cappedBlocks();
let n = 0;
for (let bi = 0; bi < blocks.length; bi++) {
n += blocks[bi].items.length;
if (blocks[bi].more) n++;
}
return n;
}
// navRows(): the SINGLE keyboard/aria source of truth. Returns the EXACT
// filteredOptions() reference when not capped and not creatable (byte-
// identical-off — untouched virtual/ungrouped keyboard path); flattens
// cappedBlocks() into visible items + more-rows, in order, when capped.
// Appends the create row, AFTER the full flattened visible(+more) sequence,
// whenever isCreatableQuery() — R3's locked "renders last, after all options
// and group sections" is a positional fact here, not a per-branch special case.
function navRows() {
if (!isCapped()) {
const base = filteredOptions();
if (!isCreatableQuery()) return base;
return base.concat([createRowAt(base.length)]);
}
const out = [];
const blocks = cappedBlocks();
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
for (let ii = 0; ii < blk.items.length; ii++) out.push(blk.items[ii]);
if (blk.more) out.push(blk.more);
}
if (isCreatableQuery()) out.push(createRowAt(out.length));
return out;
}
// 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;
}
// D-05 windowing.rzts host-contract one-liner (Phase 87 87-02). rowsWindowed() preserves
// today's EXACT truthiness (byte-behavior-identical) — it is the REQUIRED symbol
// windowing.rzts calls in place of a bare `$props.virtual` read.
//
// GAP-CLOSURE 87-16 (WR-02): the column-axis host-contract symbols (`colVirtualizer`,
// `colsWindowed()`, `columnCount()`, `columnSize()`, `forcedColumns()`) that 87-02 added
// alongside this were REMOVED here — they were dead code shipped on a mistaken premise
// about the compiler's tree-shaking BFS. Combobox imports only `{ virtualItemKey,
// virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow }`
// from windowing.rzts; none of those functions' bodies reference the column-axis symbols
// (only `columnVirtualizerOptions()`/`windowedColIndices()`/`colPadLeft()`/`colPadRight()`/
// `colIsOutsideWindow()` do, and Combobox never imports any of those), so
// `inlineScriptPartials()`'s BFS never needed them to exist. See 87-REVIEW.md WR-02 /
// 87-16-SUMMARY.md for the verification trail.
function rowsWindowed() {
return !!local.virtual;
}
// autoMeasureOn() (Phase 87 87-07, D-18/D-20): the content-driven-estimate host-contract
// gate. Combobox never lights this branch — a permanent `false` keeps windowing.rzts's
// estimateRowSize()/refineRowEstimate() accumulator dead code here. RETAINED (unlike the
// column-axis symbols above): `virtualizerOptions()` — which Combobox DOES import and call
// — wires `estimateSize: (i) => estimateRowSize(i)`, and `estimateRowSize()` calls
// `autoMeasureOn()` as its first line. This one IS reachable through the import graph.
function autoMeasureOn(): boolean {
return false;
}
// Keep $data.rows === windowSource() so the windowing math indexes the live filtered set.
function syncRows() {
setRows(windowSource());
}
// SCROLL-END PIN (the data-table D-19 twin, shared shape with Listbox): keep a user who
// scrolled to the END of a variable-height list at the end while the options in view measure
// taller than their estimate. The view is judged on the DOM, and only at a move the USER
// made — a move is virtual-core's own when it still holds an unreconciled scroll adjustment
// (scrollAdjustments !== 0): its above-viewport compensation writes an ABSOLUTE scrollTop
// computed from its last-observed (stale) offset, so it pulls the view back up from the end
// and must neither clear the pin nor be mistaken for the user leaving the end. That position
// is remembered so the scroll event that later reports it is not read as a user move either.
// (Judging on virtual-core's MODEL, as the data-table host does, fails here: its total grows
// with every option measured in the ResizeObserver batch while its offset stays at the stale
// value, so the pin was cleared mid-batch — every target ended 10-126px short, measured.)
function recordScrollEnd() {
if (!virtualizer || !gridScrollEl || virtualizer.scrollState) return;
const top: number = gridScrollEl.scrollTop;
if (top === scrollEndPinnedTop) return;
scrollEndPinnedTop = top;
if (virtualizer.scrollAdjustments !== 0) return;
// Only a list that actually overflows has an end to hold: while the window has not painted
// yet (or the list is closed), scrollHeight <= clientHeight reads as "at the end" and a pin
// recorded then would jump the freshly opened list to the bottom.
const sh = gridScrollEl.scrollHeight;
const ch = gridScrollEl.clientHeight;
scrollEndPinned = ch > 0 && sh - ch > 1 && sh - top - ch <= 1;
scrollEndPinnedCount = windowSource().length;
}
// Re-apply the pin after the framework has committed the window (called from the rAF pass):
// the real maximum is known only then. Not while a programmatic scroll (scrollToIndex) is in
// flight, and not when the option count changed since the user reached the end (a new query
// or appended options must not be auto-followed).
function keepScrollEnd() {
if (!scrollEndPinned || !virtualizer || !gridScrollEl || virtualizer.scrollState) return;
if (windowSource().length !== scrollEndPinnedCount) return;
const maxTop: number = gridScrollEl.scrollHeight - gridScrollEl.clientHeight;
if (maxTop - gridScrollEl.scrollTop > 1) {
gridScrollEl.scrollTop = maxTop;
scrollEndPinnedTop = gridScrollEl.scrollTop;
}
}
// 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() {
recordScrollEnd();
if (remeasurePending) return;
remeasurePending = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
// N-05 (quick 260923-rrr): key the rAF pass on the OUTCOME. React and Angular commit the
// recycled window AFTER the first rAF, so one pass measured the OLD options and the new ones
// waited for virtual-core's 150ms scrolling-ended tick — with variable-height options the late
// above-viewport adjustment then moved the whole list (measured). Re-run next frame until the
// committed options cover the virtualizer's window, bounded (the data-table host twin).
let rafAttempts = 0;
const rafPass = () => {
const covered = remeasureWindow();
rafAttempts = rafAttempts + 1;
if (!covered && rafAttempts < 10 && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(rafPass);
return;
}
keepScrollEnd();
remeasurePending = false;
};
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 true;
if (virtualizer.scrollState) return true;
const els = gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]');
const rendered = new Set();
for (const el of els as any) {
virtualizer.measureElement(el);
rendered.add(el.getAttribute('data-index'));
}
// N-05: false while the framework has not yet committed the recycled window.
const items = virtualizer.getVirtualItems();
for (let i = 0; i < items.length; i++) {
if (!rendered.has(String(items[i].index))) return false;
}
return true;
}
// Keep the active option visible inside the popup. When windowing, route through the
// virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window scrolls
// into view (the windowed-arrow-nav seam). When NOT windowing, resolve the active
// option element directly (a within-own-shadow query, Lit-safe) and scrollIntoView it
// with 'nearest' block alignment — a plain long list taller than the popup's
// max-height must also keep the active option visible during arrow navigation.
function scrollActiveIntoView() {
if (!local.virtual && isOpen() && activeIndex() >= 0) {
const list = __rozieRootRef! ? __rozieRootRef!.querySelector('.rozie-combobox-list') : null;
const opt = list ? list.querySelector('#' + optId(activeIndex())) : null;
if (opt) opt.scrollIntoView({
block: 'nearest'
});
return;
}
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();
}
// idRoot(): the id base — the `idBase` prop, else the per-instance id generated
// in $onMount (`autoId`), else the pre-mount fallback. Generated after mount (not
// during setup) so a server render and the hydrating client agree.
function idRoot() {
return local.idBase || autoId() || 'rozie-combobox';
}
function optId(i: any) {
return idRoot() + '-opt-' + i;
}
function listId() {
return idRoot() + '-list';
}
// popupVisible() (hideEmpty, COMBOBOX-SPEC item 4): whether the popup is actually
// SHOWN — open AND (unless `hideEmpty`) something to render. With `hideEmpty` an
// open popup with no option rows AND no create row counts as hidden: the list
// branches do not render, aria-expanded reports false, and Escape is left to the
// host (B4). Without `hideEmpty` this is exactly `$data.isOpen` (byte-identical-off).
function popupVisible() {
if (!isOpen()) return false;
if (!local.hideEmpty) return true;
return navRows().length > 0;
}
// The active option's id for aria-activedescendant (null when none).
function activeId() {
const list = navRows();
if (popupVisible() && activeIndex() >= 0 && list[activeIndex()]) return optId(activeIndex());
return null;
}
// activeOption() (handle verb, COMBOBOX-SPEC item 8): the highlighted RAW source
// option, or null (nothing highlighted, the popup is hidden, or the highlighted
// row is a synthetic "+N more" / create row).
function activeOption() {
const list = navRows();
const ai = activeIndex();
if (!popupVisible() || ai < 0) return null;
const row = list[ai];
if (!row || row.isMore || row.isCreate) return null;
return row.option === undefined ? null : row.option;
}
// 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;
}
// ---- multi-select membership + effective-default helpers (Phase 86 R1) -----
// Ported from @rozie-ui/headless-core/listCore.rzts's select()/isSelected()
// algorithm (also shipped, verbatim, via @rozie-ui/listbox) — PORTED, not
// imported: combobox's own open/active/query state machine is deliberately
// host-local (see the header comment above), and listCore.rzts is also
// consumed by the release-ignored listbox family, so pulling this into the
// shared partial would put listbox's frozen leaves back in scope.
//
// selectedValues(): the current selection as a de-duplicated array, tolerant
// of a null/undefined model. De-duplicates the MODEL array itself (not just
// `options`) so a re-normalized selection never reports the same value twice
// even if the model ever ends up holding a duplicate.
function selectedValues() {
const cur = value();
const arr = Array.isArray(cur) ? cur : [];
return Array.from(new Set(arr));
}
// isRowSelected(row): array membership under `multiple`, strict equality
// otherwise. Replaces every raw `opt.value === $props.value` / `wr.row.value
// === $props.value` template comparison (task 2) so all four render branches
// share exactly ONE membership check and can never disagree.
function isRowSelected(row: any) {
if (!row) return false;
if (local.multiple) return selectedValues().indexOf(row.value) !== -1;
return row.value === value();
}
// effectiveCloseOnSelect(): resolves the `closeOnSelect` sentinel (see the
// prop's own doc comment above for why the prop's default is `null`, not a
// literal `true`). Unset ⇒ `true` in single-select (today's default,
// unchanged), `false` under `multiple`; an explicit `true`/`false` from the
// consumer always wins in either mode. Every existing `closeOnSelect` read
// routes through this helper so the four render branches cannot disagree.
function effectiveCloseOnSelect() {
const v = local.closeOnSelect;
if (v === true || v === false) return v;
return !local.multiple;
}
// chipsInline() (chipLayout, COMBOBOX-SPEC item 2): chips + input on one
// wrapping row — only meaningful under `multiple`.
function chipsInline() {
return !!local.multiple && local.chipLayout === 'inline';
}
// ---- chip rail (Phase 86 R1, plan 86-05, D-13/D-16/D-18) ---------------
// chipRows(): selectedValues() (already de-duplicated — see above) mapped to
// chip-rail display rows. Each row carries the raw source `option` when it is
// still present in `options` (mirroring how filteredOptions() attaches the raw
// option to every wrapper row), or a raw-value fallback label when the option
// has disappeared from an asynchronously swapped `options` array — the locked
// R1 concurrency edge: an orphan chip persists, labelled by its raw value,
// rather than vanishing. `value` array order IS chip display order (R1
// locked); selectedValues() already preserves it.
function chipRows() {
const opts = Array.isArray(local.options) ? local.options : [];
return selectedValues().map((v: any) => {
const found = opts.find((o: any) => valueOf(o) === v);
return found ? {
value: v,
label: labelOf(found),
option: found
} : {
value: v,
label: String(v),
option: null
};
});
}
// chipRemoveLabel(row): the aria-label naming what a chip's remove control removes.
function chipRemoveLabel(row: any) {
return 'Remove ' + String(row.label);
}
// removeChipValue(v) is defined AFTER selectOption() below (not here) — React's
// emitter derives each `useCallback`'s static dependency array from the
// helpers its body calls, and `removeChipValue` calls `selectOption`. Declaring
// it before `selectOption`'s own `const` would put `selectOption` in
// `removeChipValue`'s deps array ahead of its OWN initializer in the SAME
// module scope — a real same-render TDZ (`ReferenceError` at runtime on
// React, TS2448 "used before its declaration" at typecheck). Source order
// here IS emission order for these plain top-level consts, so
// `removeChipValue` must textually follow `selectOption`.
// ---- 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`). `effectiveCloseOnSelect()` gates the popup close.
function selectOption(opt: any) {
if (!opt) return;
if (opt.isMore) {
expandGroup(opt.group);
setActiveIndex(opt._i);
return;
}
if (opt.isCreate) {
// Read locals before any write (ROZ138 idiom).
const q = inputText();
const nq = normalizedQuery();
// The double-commit latch (D-17/D-20): a second commit of the SAME
// normalized query — whether a rapid double gesture, or the async
// round-trip window before the consumer's `options` update lands — is a
// no-op. An empty/whitespace normalized query never emits either (the
// row should not even be reachable then, since isCreatableQuery() gates
// it, but this guard is cheap insurance against a stale reference).
if (!nq || nq === createdQuery()) return;
setCreatedQuery(nq);
_props.onCreate?.({
query: q
});
// D-20: after `create` fires, local UI state behaves like a pick — the
// effective close-on-select applies, and the query clears in `multiple`
// mode (ready for the next entry) and is left alone in single mode (the
// consumer's async add flows back through the ordinary `value` watch).
// `value` itself is untouched — R3 locked.
if (effectiveCloseOnSelect()) setIsOpen(false);
if (local.multiple) clearQuery(null);
setActiveIndex(-1);
return;
}
if (opt.disabled) return;
if (local.multiple) {
// Capture whether the value was already present BEFORE the toggle — this
// local is what feeds the `selected` field on the `change` payload (D-15).
const cur = selectedValues();
const wasSelected = cur.indexOf(opt.value) !== -1;
// Fresh array on every commit — in-place mutation (.push/.splice) is
// silently dropped by the React/Solid/Lit/Angular change detectors.
const next = wasSelected ? cur.filter((v: any) => v !== opt.value) : [...cur, opt.value];
setValue(next);
// D-14: clear the query on pick under `multiple` (not the option's label)
// so Backspace-removes-last stays reachable immediately after a pick.
// `opt.isRemoval` (set only by removeChipValue() below) skips this —
// removing a chip is not a pick, and clobbering whatever the user was
// mid-typing in the search box is a separate, unrelated data loss.
if (!opt.isRemoval) clearQuery(null);
if (effectiveCloseOnSelect()) setIsOpen(false);
setActiveIndex(-1);
_props.onChange?.({
value: next,
option: opt.option,
selected: !wasSelected
});
return;
}
setValue(opt.value);
setInputText(String(opt.label));
if (effectiveCloseOnSelect()) setIsOpen(false);
setActiveIndex(-1);
// D-15: `selected` is additive and always `true` in single-select.
_props.onChange?.({
value: opt.value,
option: opt.option,
selected: true
});
}
// removeChipValue(v): routes chip removal through the EXACT SAME toggle path
// selectOption() uses for a re-select — a synthetic wrapper row is enough,
// since the `multiple` branch above only reads `opt.value`/`opt.option`/
// `opt.disabled`/`opt.isMore` — so removal and toggle-off can never diverge
// into different payload shapes. Declared here, after selectOption(), not
// alongside chipRows()/chipRemoveLabel() above — see the comment there.
function removeChipValue(v: any) {
const opts = Array.isArray(local.options) ? local.options : [];
const found = opts.find((o: any) => valueOf(o) === v);
// isRemoval: true tells selectOption()'s `multiple` branch this is a
// removal, not a pick — see the D-14 comment there.
selectOption({
value: v,
option: found || null,
isRemoval: true
});
}
// onChipRemovePointerDown() (quick-260903-0s1, E1 audit finding): the POINTER
// half of the chip remove control's split binding. Deliberately empty —
// the `.prevent` modifier this is bound to (mousedown) is its ENTIRE payload:
// preventDefault on mousedown suppresses the native focus shift, which is
// what keeps the input focused, keeps onBlur() from firing, and therefore
// keeps the popup open (the CR-02 hazard commit `d02a145ef` closed). The
// removal deliberately does NOT live here: preventDefault on mousedown does
// NOT suppress the click that follows it, so a handler bound to BOTH events
// would remove the chip twice per pointer press. See onChipRemoveActivate()
// below for where the removal actually happens.
function onChipRemovePointerDown() {}
// onNativeInputChange() (release-0.8.0): the `.stop` on the input's native
// `change` is its whole payload — the native event bubbles out of the inner
// <input> on blur after an edit, and on Angular (no shadow boundary) a consumer
// `(change)` binding on <rozie-combobox> would receive that DOM Event as well as
// the component's own `change` output (the same collision popover's audit B6
// removed). Stopping it keeps `change` meaning only the component event.
function onNativeInputChange() {}
// onChipRemoveActivate(v) (quick-260903-0s1, E1 audit finding): the CLICK half
// of the split binding — the actual removal. `click` is the one event every
// activation path produces: a real pointer press (mousedown+click), Enter or
// Space on the focused button (native <button> behavior fires `click`, never
// `keydown`-observable-as-such), AND a screen reader's synthesized activation
// (which emits `click` with no preceding `mousedown` at all — the E1 defect
// this fixes). Binding removal to `click` alone covers all three with exactly
// one removal per activation.
//
// Keyboard/AT activation puts DOM focus ON the button, which this removal
// then unmounts — without an explicit refocus, focus would fall to
// `document.body`. Restore it using the EXACT idiom onFocus() above already
// uses (proven on all six targets): a queued microtask that refocuses
// `$refs.inputEl` only when it exists and is not already `document.activeElement`.
// That activeElement guard is what makes this a strict no-op on the pointer
// path — a pointer press never moves focus off the input in the first place
// (onChipRemovePointerDown's preventDefault sees to that), so this refocus
// never re-enters onFocus() and never re-selects the in-progress query.
// $refs is safe here for the same reason it is safe everywhere else in this
// file: this is a post-mount event handler, not module-init code.
//
// `.stop` on the template's `@click` binding (real-browser VR finding,
// quick-260903-0s1): on Solid and Svelte specifically — the two targets whose
// reactivity applies a DOM mutation SYNCHRONOUSLY, inside the very handler
// that triggered it, rather than batched to a microtask like the other four
// — removing this chip's own `<li>` mid-click detaches the click event's
// `target` from the document BEFORE the event finishes bubbling. Popover's
// own document-level `@click.outside($refs.anchorEl,$refs.floatingEl)`
// dismiss listener (Popover.rozie) then evaluates `anchorEl.contains(target)`
// against the NOW-DETACHED target, which is unconditionally `false` for any
// detached node — misreading this internal removal as an outside click and
// closing the popup. `.stop` (stopPropagation) keeps this click from ever
// reaching that document listener, exactly like the sibling `@mousedown.stop`
// pattern command-palette's own action-menu-affordance row already uses to
// keep an inner gesture from bubbling into an ancestor's own listener.
function onChipRemoveActivate(v: any) {
removeChipValue(v);
queueMicrotask(() => {
if (inputElRef && document.activeElement !== inputElRef) inputElRef!.focus();
});
}
// Reflect the externally-selected value into the input text. D-14: no-ops
// under `multiple` — there is no single label to mirror into the input once
// `value` holds an array, and the query is owned by chip-picking instead.
//
// quick-260903-0s1 (E2 audit finding): routed through the SAME valueOf()/
// labelOf() resolvers every other option read in this file uses
// (filteredOptions(), chipRows(), removeChipValue(), queryMatchesOption()) —
// this was the single site that still read the raw `.value`/`.label`
// properties directly. `optionValue`/`optionLabel` are documented public
// props, and the resolvers additionally carry the primitive-option fallback
// (`String(opt)` when `opt` has no `.label`) — bypassing them blanked the
// input on both the mount path ($onMount → syncQueryToValue()) and the
// external-value path ($watch(() => $props.value, ...) → syncQueryToValue()).
//
// The "not found" guard is on `opt` being neither `undefined` NOR `null`,
// deliberately not on truthiness: with primitive options the found entry IS
// the option, so a legitimate selection of an empty string or a zero would be
// discarded by a truthiness test and re-blank the input — reintroducing the
// bug in a new shape. `Array.prototype.find` returns `undefined` on a miss,
// so that is the correct miss test; the `null` check keeps a `null` option
// from rendering as the literal text "null".
function syncQueryToValue() {
if (local.multiple) return;
const opts = Array.isArray(local.options) ? local.options : [];
const opt = opts.find((o: any) => valueOf(o) === value());
setInputText(opt === undefined || opt === null ? '' : String(labelOf(opt)));
}
// ---- free-text commits (COMBOBOX-SPEC items 5-7, multiple only) --------
// delimiterList(): the `delimiters` prop normalized to an array.
function delimiterList() {
return Array.isArray(local.delimiters) ? local.delimiters : [];
}
// splitDelimiters(): the CHARACTER delimiters (everything but 'Enter'/'Tab') —
// the paste split characters.
function splitDelimiters() {
return delimiterList().filter((k: any) => k !== 'Enter' && k !== 'Tab');
}
// freeTextOn(): free-text commits are enabled under `multiple` when a delimiter
// list, a validate function, a splitPaste function or commitOnBlur is supplied.
function freeTextOn() {
return !!local.multiple && (delimiterList().length > 0 || typeof local.validate === 'function' || typeof local.splitPaste === 'function' || !!local.commitOnBlur);
}
// storedText(t): the `validate` gate + normaliser (Tags' shape), for an already
// trimmed, non-empty `t`. Returns the string to store, or null when rejected:
// absent validate ⇒ t; a string return ⇒ that string ('' rejects); any other
// truthy return (`true`) ⇒ t; a falsy return ⇒ rejected.
function storedText(t: any) {
if (typeof local.validate !== 'function') return t;
const r = local.validate(t);
if (!r) return null;
return typeof r === 'string' ? r : t;
}
// commitTexts(texts): append every not-yet-present text to `value` (ONE fresh
// array, ONE model write) and emit one `change` per committed text, each with the
// running array as of that commit. Texts already present are skipped silently.
function commitTexts(texts: any) {
let next = selectedValues();
const committed = [];
const snapshots = [];
for (let i = 0; i < texts.length; i++) {
const t = texts[i];
if (next.indexOf(t) !== -1) continue;
next = next.concat([t]);
committed.push(t);
snapshots.push(next);
}
if (committed.length > 0) setValue(next);
setActiveIndex(-1);
for (let i = 0; i < committed.length; i++) {
_props.onChange?.({
value: snapshots[i],
option: null,
selected: true,
text: committed[i]
});
}
}
// syncInputText(el, text): also write the LIVE input element. Angular compares a
// `[value]` binding against its last RENDERED value: fast typing followed by a
// commit in the same frame (before change detection rendered the typed text)
// leaves query '' === last-rendered '' — no DOM write, the typed text stays.
// Writing the element directly is idempotent on every other target.
function syncInputText(el: any, text: any) {
if (el && typeof el.value === 'string' && el.value !== text) el.value = text;
}
// setTypedText(q, el): the input text changed to `q` — by typing (onInput) or by a
// paste Combobox handled itself (insertAtCaret). Re-arms the create latch, opens
// the list, highlights the first row and emits `search`, exactly as typing does.
function setTypedText(q: any, el: any) {
setInputText(q);
syncInputText(el, q);
// Any input change re-arms the double-commit latch (D-17/D-20) — a
// freshly-typed query is a new gesture, never a repeat of whatever was
// last created.
setCreatedQuery(null);
setIsOpen(true);
setActiveIndex(0);
_props.onSearch?.({
query: q
});
}
// clearQuery(el): Combobox clearing the input text ITSELF (a pick under
// `multiple`, a create under `multiple`, a free-text commit, clear()). Emits
// `search` with '' so a host tracking the query through `search` never goes
// stale — a free-text commit of an already-selected value fires no `change`,
// so this is the host's only signal. No emit when the text was already empty.
// The live element is consulted too: on React a commit in the same frame as the
// last keystroke still sees the pre-keystroke `inputText` in its closure.
function clearQuery(el: any) {
const had = inputText() !== '' || !!(el && typeof el.value === 'string' && el.value !== '');
setInputText('');
syncInputText(el, '');
if (had) _props.onSearch?.({
query: ''
});
}
// insertAtCaret(el, text): insert `text` into the input at the caret, replacing
// the selection — what an ordinary paste does — and leave the caret after it.
function insertAtCaret(el: any, text: any) {
const cur = el && typeof el.value === 'string' ? el.value : String(inputText());
const start = el && typeof el.selectionStart === 'number' ? el.selectionStart : cur.length;
const end = el && typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
const next = cur.slice(0, start) + text + cur.slice(end);
setTypedText(next, el);
const caret = start + text.length;
if (el && typeof el.setSelectionRange === 'function') el.setSelectionRange(caret, caret);
}
// commitFreeText(raw, el): trim → validate (normalise) → commit + clear the input.
// Returns true when the text was handled (committed, or already present ⇒ just
// cleared); false when empty or rejected — rejected text stays in the input.
function commitFreeText(raw: any, el: any) {
const t = String(raw == null ? '' : raw).trim();
if (!t) return false;
const stored = storedText(t);
if (stored === null) return false;
clearQuery(el);
commitTexts([stored]);
return true;
}
// splitOnDelimiters(text): the built-in paste split — the clipboard text split on
// every CHARACTER delimiter, or null when it contains none (an ordinary paste).
function splitOnDelimiters(text: any) {
const seps = splitDelimiters();
let hasSep = false;
for (let s = 0; s < seps.length; s++) {
if (text.indexOf(seps[s]) !== -1) hasSep = true;
}
if (!hasSep) return null;
let parts = [text];
for (let s = 0; s < seps.length; s++) {
const out = [];
for (let p = 0; p < parts.length; p++) {
const pieces = String(parts[p]).split(seps[s]);
for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
}
parts = out;
}
return parts;
}
// onPaste(e) (item 6): under free-text mode the clipboard text is split — by
// `splitPaste` when supplied, else on the character delimiters — and every
// non-empty trimmed part `validate` accepts is committed (the paste is
// preventDefault-ed). The rejected parts (joined by the first delimiter) are
// inserted at the caret, replacing the selection, as an ordinary paste would be,
// so text typed before the paste is kept. A split of null (splitPaste said "not
// mine", or no delimiter in the text) leaves the paste to the browser.
function onPaste(e: any) {
if (!freeTextOn()) return;
const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
// typeof checked inline (not via a local flag) so strict TS narrows the call.
const split = typeof local.splitPaste === 'function' ? local.splitPaste(text) : splitOnDelimiters(text);
if (!Array.isArray(split)) return;
if (e) e.preventDefault();
const accepted = [];
const rejected = [];
for (let i = 0; i < split.length; i++) {
const part = String(split[i] == null ? '' : split[i]).trim();
if (!part) continue;
const stored = storedText(part);
if (stored === null) rejected.push(part);else accepted.push(stored);
}
const seps = splitDelimiters();
const rest = rejected.join(seps.length > 0 ? seps[0] + ' ' : ' ');
if (rest) insertAtCaret(e ? e.target : null, rest);
commitTexts(accepted);
}
// ---- input + keyboard handlers -----------------------------------------
function onInput(e: any) {
const q = e && e.target ? e.target.value : '';
setTypedText(q, null);
}
function onFocus(e: any) {
// Phase 86 R2 (plan 86-03), Solid-only reentrancy guard: the input now
// renders inside the composed popover's SCOPED `#anchor` slot
// (`:open="$props.open"` among its params — see the <Popover> template
// comment for why the input moved there). On Solid, a named slot invocation
// with reactive scope params is a plain closure CALL re-run whenever any
// param changes (@rozie/core's documented, intentional Solid
// slot-reactivity design — not a bug to route around at the emitter level):
// the `isOpen` write below changes the `open` param this exact handler is
// responding to, which on Solid SYNCHRONOUSLY recreates the anchor's DOM
// subtree (Solid's JSX has no virtual-DOM diffing to preserve node identity
// across a closure re-invocation) — removing the just-focused `<input>`
// fires a NATIVE blur on it, mid-call-stack, before this function even
// returns. Without the guard below, that blur's own onBlur() would
// immediately set isOpen back to false, and the deferred re-focus further
// down would restart the SAME cycle on the fresh node — an infinite
// recreate/blur/close/refocus loop. `openingInProgress` (below) tells
// onBlur "this blur is a side effect of OUR OWN isOpen write, not the user
// moving focus away" so it can skip closing. The other 5 targets diff their
// scoped-slot re-render and keep the existing, already-focused node — no
// blur ever fires there, so the guard is a no-op for them.
// disableOpenOnFocus (item 3): focus alone never opens the list — typing
// (onInput) and ArrowDown/ArrowUp (onKeydown) still do.
if (local.disableOpenOnFocus) {
if (e && e.target && e.target.select) e.target.select();
return;
}
openingInProgress = true;
setIsOpen(true);
// Cleared SYNCHRONOUSLY, immediately after the write — Solid's reactive
// cascade (if any) runs SYNCHRONOUSLY as part of that write, before this
// line executes, so the guard window covers exactly the recreate/blur
// cascade and nothing past it. A deferred (microtask) clear would leave a
// stale `true` window spanning an `await` boundary whenever the re-focus
// below re-enters onFocus, incorrectly suppressing a LATER, genuine blur.
openingInProgress = false;
if (e && e.target && e.target.select) e.target.select();
queueMicrotask(() => {
// Re-assert focus onto whatever node is CURRENT — after Solid's
// synchronous signal-write reactivity (if any) has already run and
// `$refs.inputEl` reflects the latest node — recovering focus if it was
// stranded on a since-removed one.
if (inputElRef && document.activeElement !== inputElRef) inputElRef!.focus();
});
}
// @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.
// While `pinned` (pinOpen(true)), early-return BEFORE the isOpen write — a host
// sub-surface (e.g. command-palette's action flyout) is holding focus and the
// popup must stay open until the host calls pinOpen(false) itself. While
// `openingInProgress` (Solid-only, see onFocus above), early-return too — this
// blur is a side effect of our OWN open-transition recreating the anchor's DOM,
// not the user moving focus elsewhere.
// commitOnBlur: leaving the field commits the typed text through validate (a blur
// into a pinned host sub-surface, or the Solid recreate blur, returned above).
function onBlur(e: any) {
if (pinned()) return;
if (openingInProgress) return;
setIsOpen(false);
if (local.commitOnBlur && freeTextOn()) {
const el = e ? e.target : null;
commitFreeText(el ? el.value : inputText(), el);
}
}
function onKeydown(e: any) {
// B10: ignore every key while an IME composition is active — the Enter that
// confirms a composition must never pick, commit or navigate. Read through
// `nativeEvent` when present: React's synthetic keyboard event does not carry
// `isComposing` (every other target hands the native event straight through).
const ne = e && e.nativeEvent ? e.nativeEvent : e;
if (ne && (ne.isComposing || ne.keyCode === 229)) return;
const key = e ? e.key : '';
const list = navRows();
// 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();
const visible = popupVisible();
const liveText = e && e.target ? e.target.value : '';
const highlighted = wasOpen && ai >= 0 && list[ai] ? list[ai] : null;
// Character delimiters (item 5): commit the TYPED text — never the highlighted
// option. 'Enter' / 'Tab' entries are handled in their own branches below.
if (freeTextOn() && key !== 'Enter' && key !== 'Tab' && delimiterList().indexOf(key) !== -1) {
if (e) e.preventDefault();
commitFreeText(liveText, e ? e.target : null);
return;
}
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') {
// B9: Enter with Ctrl / Meta / Alt is left to the host (e.g. a send shortcut).
const modified = !!(e && (e.ctrlKey || e.metaKey || e.altKey));
if (!modified) {
if (highlighted) {
if (e) e.preventDefault();
selectOption(highlighted);
} else if (freeTextOn() && String(liveText).trim()) {
// Free-text mode (item 7): Enter with no highlighted option commits the
// typed text (rejected text stays in the input).
if (e) e.preventDefault();
commitFreeText(liveText, e ? e.target : null);
}
}
} else if (key === 'Tab') {
// selectOnTab (item 8): pick the highlighted option while the popup is
// visible; preventDefault ONLY when it picked. A 'Tab' delimiter commits the
// typed text when nothing was picked. Otherwise Tab moves focus normally.
if (local.selectOnTab && visible && highlighted && !highlighted.disabled) {
if (e) e.preventDefault();
selectOption(highlighted);
} else if (freeTextOn() && delimiterList().indexOf('Tab') !== -1 && String(liveText).trim()) {
if (commitFreeText(liveText, e ? e.target : null) && e) e.preventDefault();
}
} else if (key === 'Escape') {
// B4: only consume Escape when the popup is actually VISIBLE.
if (visible) {
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));
}
} else if (key === 'Backspace') {
// Backspace-removes-last-chip (Tags.rozie precedent, Phase 86 R1 plan
// 86-05): guarded on `multiple` AND the LIVE input value being empty —
// read `e.target.value` directly (Tags' proven idiom), never the mirrored
// `$data.inputText`. A non-empty query falls through to normal text editing —
// nothing here removes a chip while there is text to delete.
if (local.multiple) {
const liveValue = e && e.target ? e.target.value : '';
if (liveValue === '') {
const cur = selectedValues();
if (cur.length > 0) {
if (e) e.preventDefault();
removeChipValue(cur[cur.length - 1]);
}
}
}
}
// Keep the (new) active option in view — routes through the virtualizer when
// windowing, direct scrollIntoView otherwise.
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);
}
}
// buildVirtualizer() (combobox-virtual-reactivity, VIRT-BUILD): the SINGLE virtualizer
// construction site — called from $onMount below (mount-time virtual:true) AND from the
// virtual $watch further down (a runtime false→true flip), so the mount path can never
// drift from the flip path. Guarded so a build queued (rAF-deferred by the $watch) that
// fires AFTER a flip-back is a no-op (rapid-flip idempotence), and so calling it twice
// never double-constructs.
function buildVirtualizer() {
if (!local.virtual || virtualizer) return;
// 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. 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.
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);
}
// teardownVirtualizer() (VIRT-TEARDOWN): runs the SAME per-instance cleanup fn
// $onUnmount invokes below, then nulls the instance state + bumps windowVer so the
// windowed template branch (still mounted while $props.virtual — CR-01) re-derives to
// the pre-construction fallback state instead of holding a stale virtualizer. This is
// the true→false ResizeObserver-leak fix: previously ONLY $onUnmount ever called
// virtualizerCleanup, so a runtime flip to non-virtual left the observer live.
function teardownVirtualizer() {
if (virtualizerCleanup) virtualizerCleanup();
virtualizer = null;
virtualizerCleanup = null;
gridScrollEl = null;
setWindowVer(windowVer() + 1);
}
// nextAutoId(): a page-wide counter shared by every Rozie component instance. It
// lives on globalThis (read through Reflect, which type-checks in the plain-JS and
// the TS script alike) so separately bundled copies of a leaf never hand out the
// same id. The same four lines live in Combobox, Listbox and Popover.
function nextAutoId() {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
}
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. seedQuery(text) — imperative-only: write the input text
// (and therefore filteredOptions()'s filter) without touching the `value`
// model or selection state (a command-palette #2 levels/restore-on-pop
// prerequisite — repopulating the input on back-navigation is NOT a
// selection). pinOpen(v) — imperative-only: pin (or unpin) the popup open so
// onBlur() does not collapse it while a host sub-surface holds focus, AND
// (Phase 86-07 regression fix) so the composed Popover's OWN independent
// Escape/click-outside dismissal is vetoed too via `:disable-dismiss`
// (command-palette-sub-actions prerequisite). pinOpen(false) ONLY unpins — it
// does NOT itself close the popup or move focus; that is the host's job.
// Render-neutral when never called. All four are post-mount → $refs safe.
function focus() {
return inputElRef?.focus();
}
function clear() {
// Fresh empty array under `multiple` (never in-place mutation), null in
// single mode — mirrors selectOption()'s `{ value, option, selected }`
// shape; nothing is selected after a clear, so `selected` is `false`.
const empty = local.multiple ? [] : null;
setValue(empty);
clearQuery(null);
setActiveIndex(-1);
_props.onChange?.({
value: empty,
option: null,
selected: false
});
}
function seedQuery(text: any) {
setInputText(String(text == null ? '' : text));
}
function pinOpen(v: any) {
setPinned(!!v);
}
// query() — the current input text (what the last `search` reported).
function query() {
return inputText();
}
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, 'rozie-combobox--multiple': local.multiple, 'rozie-combobox--block': local.block, 'rozie-combobox--chips-inline': chipsInline() }) + (((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="">
<Popover trigger="manual" open={isOpen()} onOpenChange={setIsOpen} bare={true} matchWidth={true} keepMounted={local.virtual} disablePositioning={local.inline} disableDismiss={local.inline || pinned()} placement={local.placement} offset={local.offset} disableFlip={local.disableFlip} disableShift={local.disableShift} idBase={idRoot()} data-rozie-s-9546115a="" anchorSlot={() => (<>
<div class={"rozie-combobox-control"} data-rozie-s-9546115a="">
{<Show when={local.multiple}><ul class={"rozie-combobox-chips"} data-rozie-s-9546115a="">
<Key each={chipRows() as readonly any[]} by={(row) => 'chip-' + row.value}>{(row, idx) => <li class={"rozie-combobox-chip"} data-rozie-s-9546115a="">
{(_props.chipSlot ?? _props.slots?.['chip'])?.({ get option() { return row().option; }, remove: () => onChipRemoveActivate(row().value), get index() { return idx(); } }) ?? <><span class={"rozie-combobox-chip__label"} data-rozie-s-9546115a="">{rozieDisplay(row().label)}</span><button type="button" aria-label={rozieAttr(chipRemoveLabel(row()))} class={"rozie-combobox-chip__remove"} disabled={!!local.disabled} onMouseDown={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { $event.preventDefault(); onChipRemovePointerDown(); }} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { $event.stopPropagation(); onChipRemoveActivate(row().value); }} data-rozie-s-9546115a="">×</button></>}
</li>}</Key>
</ul></Show>}<input type="text" role="combobox" aria-autocomplete="list" aria-expanded={!!popupVisible()} 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={inputText()} placeholder={local.placeholder} disabled={!!local.disabled} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onInput($event); }} onFocus={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onFocus($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onBlur($event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onKeydown($event); }} onPaste={($event: ClipboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onPaste($event); }} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { $event.stopPropagation(); onNativeInputChange(); }} data-rozie-s-9546115a="" />
</div>
</>)}>
{<Show when={popupVisible() && !local.virtual && !isGrouped()}><ul class={"rozie-combobox-list"} id={rozieAttr(listId())} role="listbox" aria-multiselectable={(local.multiple ? 'true' : null) ?? undefined} data-rozie-s-9546115a="">
<Key each={filteredOptions() as readonly any[]} by={(opt) => opt.value}>{(opt) => <li role="option" aria-selected={!!isRowSelected(opt())} aria-disabled={!!opt().disabled} class={"rozie-combobox-option" + " " + rozieClass({ 'rozie-combobox-option--active': opt()._i === activeIndex(), 'rozie-combobox-option--selected': isRowSelected(opt()), 'rozie-combobox-option--disabled': opt().disabled })} id={rozieAttr(optId(opt()._i))} onMouseDown={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { $event.preventDefault(); selectOption(opt()); }} onMouseEnter={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { setActiveIndex(opt()._i); }} data-rozie-s-9546115a="">
{(_props.optionSlot ?? _props.slots?.['option'])?.({ get option() { return opt().option; }, get index() { return opt()._i; }, get active() { return opt()._i === activeIndex(); }, get selected() { return isRowSelected(opt()); }, get disabled() { return opt().disabled; } }) ?? rozieDisplay(opt().label)}
</li>}</Key>
{<Show when={filteredOptions().length === 0 && !isCreatableQuery()}><li class={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(_props.emptySlot ?? _props.slots?.['empty'])?.({ get query() { return inputText(); } }) ?? "No results"}
</li></Show>}{<Show when={isCreatableQuery()}><li role="option" class={"rozie-combobox-option rozie-combobox-create" + " " + rozieClass({ 'rozie-combobox-option--active': filteredOptions().length === activeIndex() })} id={rozieAttr(optId(filteredOptions().length))} onMouseDown={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { $event.preventDefault(); selectOption(createRowAt(filteredOptions().length)); }} onMouseEnter={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { setActiveIndex(filteredOptions().length); }} data-rozie-s-9546115a="">
{(_props.createSlot ?? _props.slots?.['create'])?.({ get query() { return inputText(); } }) ?? <>Create "{inputText()}"</>}
</li></Show>}</ul></Show>}{<Show when={popupVisible() && !local.virtual && isGrouped() && !isCapped()}><ul class={"rozie-combobox-list"} id={rozieAttr(listId())} role="listbox" aria-multiselectable={(local.multiple ? 'true' : null) ?? undefined} data-rozie-s-9546115a="">
<Key each={groupBlocks() as readonly any[]} by={(blk) => 'grp-' + (blk.group ? blk.group.id : '_ungrouped')}>{(blk) => <li class={"rozie-combobox-group"} role="group" aria-label={rozieAttr(blk().group ? blk().group.label : null)} data-rozie-s-9546115a="">
{<Show when={blk().group}><div class={"rozie-combobox-group-heading"} role="presentation" data-rozie-s-9546115a="">
{(_props.groupHeadingSlot ?? _props.slots?.['groupHeading'])?.({ get group() { return blk().group; } }) ?? rozieDisplay(blk().group.label)}
</div></Show>}<Key each={blk().items as readonly any[]} by={(opt) => opt.value}>{(opt) => <div role="option" aria-selected={!!isRowSelected(opt())} aria-disabled={!!opt().disabled} class={"rozie-combobox-option" + " " + rozieClass({ 'rozie-combobox-option--active': opt()._i === activeIndex(), 'rozie-combobox-option--selected': isRowSelected(opt()), 'rozie-combobox-option--disabled': opt().disabled })} id={rozieAttr(optId(opt()._i))} onMouseDown={($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { $event.preventDefault(); selectOption(opt()); }} onMouseEnter={($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { setActiveIndex(opt()._i); }} data-rozie-s-9546115a="">
{(_props.optionSlot ?? _props.slots?.['option'])?.({ get option() { return opt().option; }, get index() { return opt()._i; }, get active() { return opt()._i === activeIndex(); }, get selected() { return isRowSelected(opt()); }, get disabled() { return opt().disabled; } }) ?? rozieDisplay(opt().label)}
</div>}</Key>
</li>}</Key>
{<Show when={groupBlocks().length === 0 && !isCreatableQuery()}><li class={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(_props.emptySlot ?? _props.slots?.['empty'])?.({ get query() { return inputText(); } }) ?? "No results"}
</li></Show>}{<Show when={isCreatableQuery()}><li role="option" class={"rozie-combobox-option rozie-combobox-create" + " " + rozieClass({ 'rozie-combobox-option--active': filteredOptions().length === activeIndex() })} id={rozieAttr(optId(filteredOptions().length))} onMouseDown={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { $event.preventDefault(); selectOption(createRowAt(filteredOptions().length)); }} onMouseEnter={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { setActiveIndex(filteredOptions().length); }} data-rozie-s-9546115a="">
{(_props.createSlot ?? _props.slots?.['create'])?.({ get query() { return inputText(); } }) ?? <>Create "{inputText()}"</>}
</li></Show>}</ul></Show>}{<Show when={popupVisible() && !local.virtual && isCapped()}><ul class={"rozie-combobox-list"} id={rozieAttr(listId())} role="listbox" aria-multiselectable={(local.multiple ? 'true' : null) ?? undefined} data-rozie-s-9546115a="">
<Key each={cappedBlocks() as readonly any[]} by={(blk) => 'grp-' + (blk.group ? blk.group.id : '_ungrouped')}>{(blk) => <li class={"rozie-combobox-group"} role="group" aria-label={rozieAttr(blk().group ? blk().group.label : null)} data-rozie-s-9546115a="">
{<Show when={blk().group}><div class={"rozie-combobox-group-heading"} role="presentation" data-rozie-s-9546115a="">
{(_props.groupHeadingSlot ?? _props.slots?.['groupHeading'])?.({ get group() { return blk().group; } }) ?? rozieDisplay(blk().group.label)}
</div></Show>}<Key each={blk().items as readonly any[]} by={(opt) => opt.value}>{(opt) => <div role="option" aria-selected={!!isRowSelected(opt())} aria-disabled={!!opt().disabled} class={"rozie-combobox-option" + " " + rozieClass({ 'rozie-combobox-option--active': opt()._i === activeIndex(), 'rozie-combobox-option--selected': isRowSelected(opt()), 'rozie-combobox-option--disabled': opt().disabled })} id={rozieAttr(optId(opt()._i))} onMouseDown={($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { $event.preventDefault(); selectOption(opt()); }} onMouseEnter={($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { setActiveIndex(opt()._i); }} data-rozie-s-9546115a="">
{(_props.optionSlot ?? _props.slots?.['option'])?.({ get option() { return opt().option; }, get index() { return opt()._i; }, get active() { return opt()._i === activeIndex(); }, get selected() { return isRowSelected(opt()); }, get disabled() { return opt().disabled; } }) ?? rozieDisplay(opt().label)}
</div>}</Key>
{<Show when={blk().more}><div role="option" class={"rozie-combobox-option rozie-combobox-more" + " " + rozieClass({ 'rozie-combobox-option--active': blk().more._i === activeIndex() })} id={rozieAttr(optId(blk().more._i))} onMouseDown={($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { $event.preventDefault(); selectOption(blk().more); }} onMouseEnter={($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { setActiveIndex(blk().more._i); }} data-rozie-s-9546115a="">
{(_props.groupMoreSlot ?? _props.slots?.['groupMore'])?.({ get group() { return blk().group; }, get hidden() { return blk().more.hidden; }, get expand() { return blk().more.expand; } }) ?? <>+{rozieDisplay(blk().more.hidden)} more</>}
</div></Show>}</li>}</Key>
{<Show when={cappedBlocks().length === 0 && !isCreatableQuery()}><li class={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(_props.emptySlot ?? _props.slots?.['empty'])?.({ get query() { return inputText(); } }) ?? "No results"}
</li></Show>}{<Show when={isCreatableQuery()}><li role="option" class={"rozie-combobox-option rozie-combobox-create" + " " + rozieClass({ 'rozie-combobox-option--active': cappedRowCount() === activeIndex() })} id={rozieAttr(optId(cappedRowCount()))} onMouseDown={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { $event.preventDefault(); selectOption(createRowAt(cappedRowCount())); }} onMouseEnter={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { setActiveIndex(cappedRowCount()); }} data-rozie-s-9546115a="">
{(_props.createSlot ?? _props.slots?.['create'])?.({ get query() { return inputText(); } }) ?? <>Create "{inputText()}"</>}
</li></Show>}</ul></Show>}{<Show when={local.virtual}><ul class={"rozie-combobox-list rozie-combobox-list--virtual"} id={rozieAttr(listId())} role="listbox" aria-multiselectable={(local.multiple ? 'true' : null) ?? undefined} style={parseInlineStyle((popupVisible() ? '' : '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="" />
<Key each={windowedView() as readonly any[]} by={(wr) => wr.row.id}>{(wr) => <li data-index={rozieAttr(wr().vi.index)} role="option" aria-selected={!!isRowSelected(wr().row)} aria-disabled={!!wr().row.disabled} class={"rozie-combobox-option" + " " + rozieClass({ 'rozie-combobox-option--active': wr().vi.index === activeIndex(), 'rozie-combobox-option--selected': isRowSelected(wr().row), 'rozie-combobox-option--disabled': wr().row.disabled })} id={rozieAttr(optId(wr().vi.index))} onMouseDown={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { $event.preventDefault(); selectOption(wr().row); }} onMouseEnter={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { setActiveIndex(wr().vi.index); }} data-rozie-s-9546115a="">
{(_props.optionSlot ?? _props.slots?.['option'])?.({ get option() { return wr().row.option; }, get index() { return wr().vi.index; }, get active() { return wr().vi.index === activeIndex(); }, get selected() { return isRowSelected(wr().row); }, get disabled() { return wr().row.disabled; } }) ?? rozieDisplay(wr().row.label)}
</li>}</Key>
<li class={"rozie-combobox-spacer"} aria-hidden="true" style={parseInlineStyle('height:' + padBottom() + 'px')} data-rozie-s-9546115a="" />
{<Show when={windowSource().length === 0 && !isCreatableQuery()}><li class={"rozie-combobox-empty"} role="presentation" data-rozie-s-9546115a="">
{(_props.emptySlot ?? _props.slots?.['empty'])?.({ get query() { return inputText(); } }) ?? "No results"}
</li></Show>}{<Show when={isCreatableQuery()}><li role="option" class={"rozie-combobox-option rozie-combobox-create" + " " + rozieClass({ 'rozie-combobox-option--active': windowSource().length === activeIndex() })} id={rozieAttr(optId(windowSource().length))} onMouseDown={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { $event.preventDefault(); selectOption(createRowAt(windowSource().length)); }} onMouseEnter={($event: MouseEvent & { currentTarget: HTMLLIElement; target: Element }) => { setActiveIndex(windowSource().length); }} data-rozie-s-9546115a="">
{(_props.createSlot ?? _props.slots?.['create'])?.({ get query() { return inputText(); } }) ?? <>Create "{inputText()}"</>}
</li></Show>}</ul></Show>}</Popover>
</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 { RozieSlotDistributor, createLitControllableProperty, rozieAttr, rozieDisplay, rozieListeners, rozieSpread, rozieStyle } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
import '@rozie-ui/popover-lit';
// 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';
// ---- native option grouping (combobox-native-groups: src/internal/groupOptions.ts) ----
// The PURE stable-partition helper is a RUNTIME import (unlike listCore/windowing
// above, it is NOT a compile-time `.rzts` partial that dissolves at compile) —
// codegen's `copyInternal` vendors it verbatim into each leaf at
// `./internal/groupOptions`, mirroring command-palette's `scoreCommands.ts`.
import { groupOptions } from './internal/groupOptions';
// 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.
// The typed public surface (typed-surface P1; always TypeScript). `value` /
// `option` stay `any`: options are consumer-shaped objects (or primitives) the
// component never inspects beyond the label/value/disabled resolvers.
/** `search` payload — the current input text. */
export interface ComboboxSearchPayload {
query: string;
}
/** `change` payload — `option` is the raw source option (`null` for a clear or a free-text commit); `text` is set ONLY on free-text commits. */
export interface ComboboxChangePayload {
value: any;
option: any;
selected: boolean;
text?: string;
}
/** `create` payload — the (untrimmed) query the user asked to create. */
export interface ComboboxCreatePayload {
query: string;
}
/** An entry of the `groups` prop. */
export interface ComboboxGroup {
id: string;
label: string;
}
/** `chip` slot params — `remove()` removes the chip and refocuses the input. */
export interface ComboboxChipSlotCtx {
option: any;
remove: () => void;
index: number;
}
/** `option` slot params. */
export interface ComboboxOptionSlotCtx {
option: any;
index: number;
active: boolean;
selected: boolean;
disabled: boolean;
}
/** `empty` / `create` slot params. */
export interface ComboboxQuerySlotCtx {
query: string;
}
/** `groupHeading` slot params. */
export interface ComboboxGroupHeadingSlotCtx {
group: ComboboxGroup;
}
/** `groupMore` slot params. */
export interface ComboboxGroupMoreSlotCtx {
group: ComboboxGroup | null;
hidden: number;
expand: () => void;
}
export interface RozieComboboxEventMap extends Omit<HTMLElementEventMap, 'search' | 'change' | 'create' | 'value-change'> {
'search': CustomEvent<ComboboxSearchPayload>;
'change': CustomEvent<ComboboxChangePayload>;
'create': CustomEvent<ComboboxCreatePayload>;
'value-change': CustomEvent<unknown>;
}
interface RozieChipSlotCtx {
option: any;
remove: () => void;
index: number;
}
interface RozieOptionSlotCtx {
option: any;
index: number;
active: boolean;
selected: boolean;
disabled: boolean;
}
interface RozieEmptySlotCtx {
query: string;
}
interface RozieCreateSlotCtx {
query: string;
}
interface RozieGroupHeadingSlotCtx {
group: ComboboxGroup;
}
interface RozieGroupMoreSlotCtx {
group: ComboboxGroup | null;
hidden: number;
expand: () => void;
}
@customElement('rozie-combobox')
export default class Combobox extends SignalWatcher(LitElement) {
static shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, slotAssignment: 'manual' };
static styles = css`
:host{display:contents}
.rozie-combobox[data-rozie-s-9546115a] {
position: relative;
display: inline-block;
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
font: var(--rozie-combobox-font, inherit);
}
.rozie-combobox-input[data-rozie-s-9546115a] {
box-sizing: border-box;
/* Phase 86 R2 (plan 86-03): EXPLICIT width, not \`100%\`. The input now renders
inside popover's \`.rozie-popover-anchor\` (\`display: inline-block\`,
shrink-to-fit) rather than as a direct 100%-width child of \`.rozie-combobox\`
(\`width: var(--rozie-combobox-width, var(--rcb-width, 16rem))\`) — a percentage width here would
be circular against that shrink-to-fit ancestor (CSS 2.1 §10.3.3: an
unresolvable percentage against an auto-width parent degrades to the
intrinsic/auto size, NOT the control's real width), which is exactly the
bug this fixes: \`anchorEl\`'s measured rect must equal the input's real box
for Floating UI's positioning AND \`matchWidth\`'s reference width to be
correct. Reads the SAME \`--rozie-combobox-width\` token \`.rozie-combobox\`
itself uses, so the rendered pixel width is IDENTICAL to before this change
in the default (non-inline) case. \`.rozie-combobox--inline
.rozie-combobox-input\` below restores \`100%\` for the inline pass-through
path, where \`.rozie-combobox\` itself stretches to its container (unaffected
by this fix — \`disablePositioning\` skips anchor measurement entirely there). */
width: var(--rozie-combobox-width, var(--rcb-width, 16rem));
padding: var(--rozie-combobox-input-padding, var(--rcb-input-padding, 0.5rem 0.75rem));
font: inherit;
color: var(--rozie-combobox-color, var(--rcb-color, inherit));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
/*
Render-neutral bottom-divider token (260715-50l finding 3). A longhand
AFTER the \`border:\` shorthand above so it wins on the bottom side; the
fallback REPLICATES the shorthand's own bottom (border-width solid
border-color) so default rendering is byte-for-render unchanged. Lets a
consumer (e.g. command-palette) render a borderless-with-underline input
without touching the other three sides.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25))));
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox-input[data-rozie-s-9546115a]:focus {
/* Decoupled from --rozie-combobox-accent (finding 3) so a consumer can */
/* neutralize the focus BORDER without touching the selected-option accent. */
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
/*
Same underline token, focus-colored fallback — the longhand keeps
WINNING on the bottom side over the :focus border-color override above,
so a consumer-set divider survives both blurred and focused states.
*/
border-bottom: var(--rozie-combobox-input-underline, var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc))));
}
.rozie-combobox--disabled[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-disabled-opacity, var(--rcb-disabled-opacity, 0.55));
background: var(--rozie-combobox-disabled-bg, var(--rcb-disabled-bg, rgba(0, 0, 0, 0.04)));
}
.rozie-combobox-list[data-rozie-s-9546115a] {
margin: 0;
padding: var(--rozie-combobox-list-padding, var(--rcb-list-padding, 0.25rem));
list-style: none;
max-height: var(--rozie-combobox-list-max-height, var(--rcb-list-max-height, 16rem));
overflow-y: auto;
background: var(--rozie-combobox-list-bg, var(--rcb-list-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-list-border-color, var(--rcb-list-border-color, rgba(0, 0, 0, 0.15)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
box-shadow: var(--rozie-combobox-list-shadow, var(--rcb-list-shadow, 0 10px 24px rgba(0, 0, 0, 0.16)));
}
.rozie-combobox-option[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-option-padding, var(--rcb-option-padding, 0.4rem 0.6rem));
border-radius: var(--rozie-combobox-option-radius, var(--rcb-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, var(--rcb-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, var(--rcb-option-selected-weight, 600));
color: var(--rozie-combobox-option-selected-color, var(--rozie-combobox-accent, var(--rcb-option-selected-color, var(--rcb-accent, #0066cc))));
}
.rozie-combobox-option--disabled[data-rozie-s-9546115a] {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-empty[data-rozie-s-9546115a] {
padding: var(--rozie-combobox-empty-padding, var(--rcb-empty-padding, 0.5rem 0.6rem));
color: var(--rozie-combobox-empty-color, var(--rcb-empty-color, rgba(0, 0, 0, 0.5)));
list-style: none;
}
.rozie-combobox-group[data-rozie-s-9546115a] {
list-style: none;
}
.rozie-combobox-group-heading[data-rozie-s-9546115a] {
/* Render-neutral section-separation token (260715-50l finding 4) — default */
/* 0 = unchanged; a consumer-set value separates the leading ungrouped */
/* block from the first group heading. */
margin-top: var(--rozie-combobox-group-heading-margin-top, var(--rcb-group-heading-margin-top, 0));
padding: var(--rozie-combobox-group-heading-padding, var(--rcb-group-heading-padding, 0.35rem 0.6rem 0.15rem));
font-size: var(--rozie-combobox-group-heading-size, var(--rcb-group-heading-size, 0.75rem));
font-weight: var(--rozie-combobox-group-heading-weight, var(--rcb-group-heading-weight, 600));
text-transform: var(--rozie-combobox-group-heading-transform, var(--rcb-group-heading-transform, uppercase));
letter-spacing: var(--rozie-combobox-group-heading-letter-spacing, var(--rcb-group-heading-letter-spacing, 0.03em));
color: var(--rozie-combobox-group-heading-color, var(--rcb-group-heading-color, rgba(0, 0, 0, 0.5)));
pointer-events: none;
user-select: none;
}
.rozie-combobox-more[data-rozie-s-9546115a] {
cursor: pointer;
color: var(--rozie-combobox-more-color, var(--rcb-more-color, rgba(0, 0, 0, 0.55)));
font-size: var(--rozie-combobox-more-size, var(--rcb-more-size, 0.875rem));
}
.rozie-combobox-create[data-rozie-s-9546115a] {
cursor: pointer;
color: var(--rozie-combobox-create-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
background: var(--rozie-combobox-create-bg, var(--rcb-create-bg, transparent));
}
.rozie-combobox-spacer[data-rozie-s-9546115a] { margin: 0; padding: 0; border: 0; list-style: none; }
.rozie-combobox-list--virtual[data-rozie-s-9546115a] { overflow-anchor: none; }
.rozie-combobox-chips[data-rozie-s-9546115a] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
padding: var(--rozie-combobox-chips-padding, var(--rcb-chips-padding, 0.35rem 0.45rem 0 0.45rem));
margin: 0;
list-style: none;
}
.rozie-combobox-chip[data-rozie-s-9546115a] {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: var(--rozie-combobox-chip-padding, var(--rcb-chip-padding, 0.15rem 0.5rem));
font-size: var(--rozie-combobox-chip-size, var(--rcb-chip-size, 0.85rem));
color: var(--rozie-combobox-chip-color, inherit);
background: var(--rozie-combobox-chip-bg, var(--rcb-chip-bg, rgba(0, 102, 204, 0.12)));
border-radius: var(--rozie-combobox-chip-radius, var(--rcb-chip-radius, 0.375rem));
white-space: nowrap;
}
.rozie-combobox-chip__remove[data-rozie-s-9546115a] {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
height: var(--rozie-combobox-chip-remove-size, var(--rcb-chip-remove-size, 1.1rem));
padding: 0;
font: inherit;
line-height: 1;
color: var(--rozie-combobox-chip-remove-color, var(--rcb-chip-remove-color, currentColor));
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
transition: color 0.15s;
}
.rozie-combobox-chip__remove[data-rozie-s-9546115a]:hover:not([data-rozie-s-9546115a]:disabled) {
color: var(--rozie-combobox-chip-remove-hover-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
}
.rozie-combobox-chip__remove[data-rozie-s-9546115a]:disabled {
cursor: not-allowed;
opacity: var(--rozie-combobox-option-disabled-opacity, var(--rcb-option-disabled-opacity, 0.45));
}
.rozie-combobox-control[data-rozie-s-9546115a] {
display: contents;
}
.rozie-combobox--block[data-rozie-s-9546115a] {
display: block;
width: 100%;
container-type: inline-size;
}
.rozie-combobox--block[data-rozie-s-9546115a] .rozie-combobox-control[data-rozie-s-9546115a] {
display: block;
width: 100cqw;
}
.rozie-combobox--block[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
width: 100%;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] {
container-type: inline-size;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-control[data-rozie-s-9546115a] {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rozie-combobox-chip-gap, var(--rcb-chip-gap, 0.4rem));
width: 100cqw;
padding: var(--rozie-combobox-inline-padding, var(--rcb-inline-padding, 0.3rem 0.45rem));
background: var(--rozie-combobox-bg, var(--rcb-bg, #fff));
border: var(--rozie-combobox-border-width, var(--rcb-border-width, 1px)) solid var(--rozie-combobox-border-color, var(--rcb-border-color, rgba(0, 0, 0, 0.25)));
border-radius: var(--rozie-combobox-radius, var(--rcb-radius, 0.5rem));
transition: border-color 0.15s, box-shadow 0.15s;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-control[data-rozie-s-9546115a]:focus-within {
border-color: var(--rozie-combobox-focus-border-color, var(--rozie-combobox-accent, var(--rcb-accent, #0066cc)));
box-shadow: 0 0 0 var(--rozie-combobox-focus-ring-width, var(--rcb-focus-ring-width, 3px)) var(--rozie-combobox-focus-ring-color, var(--rcb-focus-ring-color, rgba(0, 102, 204, 0.25)));
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-chips[data-rozie-s-9546115a] {
display: contents;
}
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a],
.rozie-combobox--chips-inline[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a]:focus {
flex: 1 1 var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
width: auto;
min-width: var(--rozie-combobox-inline-input-min-width, var(--rcb-inline-input-min-width, 6rem));
padding: var(--rozie-combobox-inline-input-padding, var(--rcb-inline-input-padding, 0.2rem 0.25rem));
background: transparent;
border: none;
box-shadow: 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\` dropped (plan 86-03): \`.rozie-combobox-list\` carries no
absolute positioning to undo anymore — that geometry lives on popover's
\`.rozie-popover-floating\`, and \`:disable-positioning="$props.inline"\`
(D-09) already renders it as a static pass-through via popover's own
\`.rozie-popover-floating--static\` rule. */
margin-top: var(--rozie-combobox-list-gap, var(--rcb-list-gap, 0.25rem));
border: none;
border-radius: 0;
box-shadow: none;
}
.rozie-combobox--inline[data-rozie-s-9546115a] .rozie-combobox-input[data-rozie-s-9546115a] {
width: 100%;
}
`;
/**
* 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
* <rozie-combobox .value=${country} @value-change=${…} .options=${countries}></rozie-combobox>
*/
@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?, group? }]`. `label` is the displayed text (and what client filtering matches against), `value` is what `r-model:value` reads and writes, an optional `disabled` flag makes an option non-selectable, and an optional `group` string buckets the option under a matching entry of the `groups` prop (or a first-appearance fallback section) when grouping is active.
*/
@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, attribute: 'disable-filter' }) 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, attribute: 'aria-label' }) ariaLabel: string | null = null;
/**
* Id base for the listbox, option and popup elements — `aria-activedescendant` needs real ids. Option ids are derived as `idBase + "-opt-" + i`, the listbox id is `idBase + "-list"`. Leave it empty (the default) and each instance generates a unique id base after mount (`rozie-combobox-<n>`); set it when you need stable, predictable ids. Named `idBase` (not `id`) to avoid shadowing `HTMLElement.id` on the Lit custom element.
*/
@property({ type: String, reflect: true, attribute: 'id-base' }) 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).
*/
@property({ type: Boolean, reflect: true }) inline: boolean = false;
/**
* Close the popup after a selection commits. Unset (default) resolves through `effectiveCloseOnSelect()`: `true` in single-select (today's default behavior) and `false` in `multiple` mode, where closing after every chip pick would make multi-select unusable. Pass an explicit `true` or `false` to override in either mode.
*/
@property({ type: Boolean, reflect: true, attribute: 'close-on-select' }) closeOnSelect: boolean | null = null;
/**
* `value` widens to hold an **array** of selected values and remains the sole `model: true` prop, so the Angular `ControlValueAccessor` is preserved (a second model would forfeit it — `ROZ125`). Re-selecting an already-selected option toggles it off. Default `false` is byte-identical to single-select.
*/
@property({ type: Boolean, reflect: true }) multiple: boolean = false;
/**
* When the user commits text matching no option (case-insensitive, trimmed, exact label equality — no Unicode normalization applied), combobox emits `create` with the query and writes NOTHING to `value` — the consumer adds the option to `options` and updates the model itself. Composes with `multiple`. Turning this on replaces the `#empty` fill with the `#create` row whenever the query is creatable (non-empty, no exact match); `#empty` still renders for an empty or whitespace-only query. Default `false` is byte-identical to today.
*/
@property({ type: Boolean, reflect: true }) creatable: boolean = false;
/**
* Resolver override for an object option's display label — `(option) => string`. Falls back to the option's `.label` property.
*/
@property({ type: Function, attribute: 'option-label' }) optionLabel: ((...args: any[]) => any) | null = null;
/**
* Resolver override for an object option's committed value — `(option) => value`. Falls back to the option's `.value` property.
*/
@property({ type: Function, attribute: 'option-value' }) optionValue: ((...args: any[]) => any) | null = null;
/**
* Resolver override marking an option non-selectable — `(option) => boolean`. Falls back to the option's `.disabled` property.
*/
@property({ type: Function, attribute: 'option-disabled' }) optionDisabled: ((...args: any[]) => any) | 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, attribute: 'estimate-row-height' }) 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, attribute: 'max-height' }) maxHeight: string = '';
/**
* Ordered section list `[{ id, label }]` setting group order + heading text. Options are partitioned by their optional `group?` string; groups present on options but absent here fall back to first-appearance order after the listed ones. Empty/absent ⇒ flat, ungrouped rendering (default).
*/
@property({ type: Array }) groups: any[] = [];
/**
* Cap each native section group to its first `groupCap` results, adding a keyboard-reachable '+N more' row that expands that group IN PLACE when activated. `0`/absent = uncapped (default). Only applies to the non-virtual grouped render (`groups` non-empty); ignored when `virtual` is on.
*/
@property({ type: Number, reflect: true, attribute: 'group-cap' }) groupCap: number = 0;
/**
* Floating UI placement of the popup relative to the control, forwarded to the composed `@rozie-ui/popover` leaf — one of `top`/`right`/`bottom`/`left`, each optionally suffixed `-start`/`-end`. Default `"bottom-start"` matches the pre-Phase-86 static popup alignment (flush with the control's left edge). Ignored when `inline` is set.
*/
@property({ type: String, reflect: true }) placement: string = 'bottom-start';
/**
* Gap in pixels between the control and the popup, forwarded to the composed `@rozie-ui/popover` leaf. Default `4` preserves the pre-Phase-86 resting gap (`--rozie-combobox-list-gap`). Ignored when `inline` is set.
*/
@property({ type: Number, reflect: true }) offset: number = 4;
/**
* Disable the popup's Floating UI `flip` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup flips above the control when it would overflow the viewport below; set this to keep it pinned to `placement` regardless. Ignored when `inline` is set.
*/
@property({ type: Boolean, reflect: true, attribute: 'disable-flip' }) disableFlip: boolean = false;
/**
* Disable the popup's Floating UI `shift` middleware (forwarded to the composed `@rozie-ui/popover` leaf). By default the popup shifts to stay within the viewport; set this to keep it strictly aligned to the control. Ignored when `inline` is set.
*/
@property({ type: Boolean, reflect: true, attribute: 'disable-shift' }) disableShift: boolean = false;
/**
* Fill the container: the root becomes `display: block; width: 100%`, the control (chips + input) stretches to that width, and the width-matched popup follows. Adds the `rozie-combobox--block` modifier class on the root. Default `false` keeps the fixed `--rozie-combobox-width` sizing.
*/
@property({ type: Boolean, reflect: true }) block: boolean = false;
/**
* Chip rail layout under `multiple`: `'stacked'` (default) renders the chips above the input; `'inline'` puts the chips and the input on ONE wrapping row (the Tags layout), with the input taking the remaining width (`flex: 1`, never narrower than `--rozie-combobox-inline-input-min-width`). Only meaningful with `multiple`.
*/
@property({ type: String, reflect: true, attribute: 'chip-layout' }) chipLayout: string = 'stacked';
/**
* Do not open the list when the input gains focus. Typing and ArrowDown / ArrowUp still open it. Default `false` opens on focus.
*/
@property({ type: Boolean, reflect: true, attribute: 'disable-open-on-focus' }) disableOpenOnFocus: boolean = false;
/**
* Show nothing instead of the empty state: when there are no option rows and no create row, the popup is not shown, the input reports `aria-expanded="false"`, and Escape is left to the host (not `preventDefault`ed). This is the supported way to render no popup at all; filling the `empty` slot with nothing still renders the fallback on most targets.
*/
@property({ type: Boolean, reflect: true, attribute: 'hide-empty' }) hideEmpty: boolean = false;
/**
* Keys that commit the **typed text** as a value (matched against the key event's `key`), under `multiple` only — a delimiter never picks the highlighted option. Character entries (e.g. `[',', ';']`) also split pasted text: a paste containing a delimiter is split on them, every non-empty trimmed part that `validate` accepts is committed, and the rejected parts are inserted at the caret (replacing the selection) like an ordinary paste, so text typed before the paste is kept. Use `splitPaste` to replace this split. `'Enter'` and `'Tab'` are allowed; Enter then commits the typed text only when no option is highlighted. A non-empty list (or `validate`, `splitPaste` or `commitOnBlur`) turns on free-text commits, so Enter with no highlighted option commits the typed text too. Default `[]` (off).
* @example
* <rozie-combobox multiple .value=${to} @value-change=${…} .options=${contacts} .delimiters=${delims}></rozie-combobox>
*/
@property({ type: Array }) delimiters: any[] = [];
/**
* Free-text gate and normaliser, `(text: string) => string | boolean | null | undefined`, under `multiple` only. Called with the trimmed typed (or pasted) text before every free-text commit. Return the **string to store** (e.g. the bare address out of `Sam Roe <sam@x.test>`), `true` to store the text as typed, or a falsy value (`false` / `null` / `''`) to reject it — rejected text stays in the input. The same shape as Tags' `validate`. Setting it also turns on free-text commits (Enter with no highlighted option commits the typed text). A free-text commit appends the stored string to `value` (skipped when already present), clears the input, and emits `change` with `option: null` and the stored string as `text`.
* @example
* <rozie-combobox multiple .value=${to} @value-change=${…} .options=${contacts} .validate=${toAddress}></rozie-combobox>
*/
@property({ type: Function }) validate: ((...args: any[]) => any) | null = null;
/**
* Replaces the built-in paste split, `(text: string) => string[] | null`, under `multiple` only. Called with the clipboard text on every paste. Return the parts to commit — each is trimmed and passed through `validate`; accepted parts are committed and the rejected ones are inserted at the caret — or `null` to leave the paste to the browser untouched. Use it for syntax the delimiter split cannot know about, e.g. a quoted display name containing a comma (`"Roe, Sam" <sam@x.test>`). Setting it also turns on free-text commits.
* @example
* <rozie-combobox multiple .value=${to} @value-change=${…} .options=${contacts} .validate=${toAddress} .splitPaste=${splitAddresses}></rozie-combobox>
*/
@property({ type: Function, attribute: 'split-paste' }) splitPaste: ((...args: any[]) => any) | null = null;
/**
* Commit the typed text when the input loses focus, under `multiple` only, through `validate` like every other free-text commit: accepted text is committed and the input cleared, rejected text stays. A blur into a pinned host sub-surface (`pinOpen(true)`) does not commit. Setting it also turns on free-text commits. Default `false`.
*/
@property({ type: Boolean, reflect: true, attribute: 'commit-on-blur' }) commitOnBlur: boolean = false;
/**
* Tab picks the highlighted option while the popup is visible and an option is highlighted, keeping focus in the input. When nothing is picked, Tab moves focus normally. Default `false` (Tab always moves focus).
*/
@property({ type: Boolean, reflect: true, attribute: 'select-on-tab' }) selectOnTab: boolean = false;
private _inputText = signal('');
private _isOpen = signal(false);
private _activeIndex = signal(-1);
private _rows = signal<any[]>([]);
private _windowVer = signal(0);
private _editVer = signal(0);
private _expandedGroups = signal<any>({});
private _createdQuery = signal<any>(null);
private _pinned = signal(false);
private _autoId = signal('');
@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;
private __rozieFirstUpdateDone = false;
private _rozieSlotDistributor = new RozieSlotDistributor(this);
@state() private _hasSlotChip = false;
@queryAssignedElements({ slot: 'chip', flatten: true }) private _slotChipElements!: Element[];
@property({ attribute: false }) chip?: (scope: { option: any; remove: () => void; index: number }) => unknown;
@state() private _hasSlotOption = false;
@queryAssignedElements({ slot: 'option', flatten: true }) private _slotOptionElements!: Element[];
@property({ attribute: false }) option?: (scope: { option: any; index: number; active: boolean; selected: boolean; disabled: boolean }) => unknown;
@state() private _hasSlotEmpty = false;
@queryAssignedElements({ slot: 'empty', flatten: true }) private _slotEmptyElements!: Element[];
@property({ attribute: false }) empty?: (scope: { query: string }) => unknown;
@state() private _hasSlotCreate = false;
@queryAssignedElements({ slot: 'create', flatten: true }) private _slotCreateElements!: Element[];
@property({ attribute: false }) create?: (scope: { query: string }) => unknown;
@state() private _hasSlotGroupHeading = false;
@queryAssignedElements({ slot: 'groupHeading', flatten: true }) private _slotGroupHeadingElements!: Element[];
@property({ attribute: false }) groupHeading?: (scope: { group: ComboboxGroup }) => unknown;
@state() private _hasSlotGroupMore = false;
@queryAssignedElements({ slot: 'groupMore', flatten: true }) private _slotGroupMoreElements!: Element[];
@property({ attribute: false }) groupMore?: (scope: { group: ComboboxGroup | null; hidden: number; expand: () => void }) => unknown;
// Phase 79 Plan 08 (R4) contract for 79-09: the record intake for
// record-routed slot fills. 79-09's consumer-side emitSlotFiller
// accumulates an object literal onto the SAME `.rozieSlots=${{ ... }}`
// open-tag binding; the KEY is the fill's authored (possibly
// non-identifier) name and the VALUE is a scope-taking render
// function. `rozieSlots?.[name]` must be checked BEFORE the legacy
// named function-prop / <slot> fallback (AC-9). Attribute
// deserialization is disabled — this is a function-valued record,
// never reflected to/from an HTML attribute.
@property({ attribute: false }) rozieSlots?: Record<string, (scope: any) => 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="chip"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotChip = this._slotChipElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="option"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotOption = this._slotOptionElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="empty"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotEmpty = this._slotEmptyElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="create"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotCreate = this._slotCreateElements.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="groupHeading"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotGroupHeading = this._slotGroupHeadingElements.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="groupMore"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotGroupMore = this._slotGroupMoreElements.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._hasSlotChip = Array.from(this.children).some((el) => el.getAttribute('slot') === 'chip');
this._hasSlotOption = Array.from(this.children).some((el) => el.getAttribute('slot') === 'option');
this._hasSlotEmpty = Array.from(this.children).some((el) => el.getAttribute('slot') === 'empty');
this._hasSlotCreate = Array.from(this.children).some((el) => el.getAttribute('slot') === 'create');
this._hasSlotGroupHeading = Array.from(this.children).some((el) => el.getAttribute('slot') === 'groupHeading');
this._hasSlotGroupMore = Array.from(this.children).some((el) => el.getAttribute('slot') === 'groupMore');
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._inputText.value)(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
if (this._expandedGroups.value && Object.keys(this._expandedGroups.value).length) this._expandedGroups.value = {};
this.syncRows();
if (this.virtual && this.virtualizer) {
this.virtualizer.setOptions(this.virtualizerOptions());
this.virtualizer._willUpdate();
this._windowVer.value = this._windowVer.value + 1;
this.scheduleRemeasure();
}
})(); }); }));
if (!this.idBase) this._autoId.value = 'rozie-combobox-' + this.nextAutoId();
this.syncQueryToValue();
this.syncRows();
this.didMount = true;
// Routes through the SAME buildVirtualizer() the virtual $watch calls below
// (VIRT-BUILD) — one construction site, so the mount path cannot drift from the flip
// path.
// Routes through the SAME buildVirtualizer() the virtual $watch calls below
// (VIRT-BUILD) — one construction site, so the mount path cannot drift from the flip
// path.
if (this.virtual) this.buildVirtualizer();
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('virtual'))) { const __watchVal = (() => this.virtual)(); (() => {
if (this._expandedGroups.value && Object.keys(this._expandedGroups.value).length) this._expandedGroups.value = {};
if (this.virtual) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => this.buildVirtualizer());else setTimeout(() => this.buildVirtualizer(), 0);
} else {
this.teardownVirtualizer();
}
})(); }
this.__rozieFirstUpdateDone = true;
}
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, 'rozie-combobox--multiple': this.multiple, 'rozie-combobox--block': this.block, 'rozie-combobox--chips-inline': this.chipsInline() }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="__rozieRoot" data-rozie-s-9546115a>
<rozie-popover trigger="manual" .open=${this._isOpen.value} @open-change=${($event: CustomEvent) => { this._isOpen.value = $event.detail; }} .bare=${true} .matchWidth=${true} .keepMounted=${this.virtual} .disablePositioning=${this.inline} .disableDismiss=${this.inline || this._pinned.value} .placement=${this.placement} .offset=${this.offset} .disableFlip=${this.disableFlip} .disableShift=${this.disableShift} .idBase=${this.idRoot()} data-rozie-s-9546115a><div class="rozie-combobox-control" data-rozie-s-9546115a slot="anchor">
${this.multiple ? html`<ul class="rozie-combobox-chips" data-rozie-s-9546115a>
${repeat<any>(this.chipRows(), (row, idx) => 'chip-' + row.value, (row, idx) => html`<li class="rozie-combobox-chip" data-rozie-s-9546115a>
${this.chip !== undefined ? this.chip({option: row.option, remove: () => this.onChipRemoveActivate(row.value), index: idx}) : html`<slot name="chip" data-rozie-params=${(() => { try { return JSON.stringify({option: row.option, index: idx}); } catch { return '{}'; } })()} @rozie-chip-remove=${($event: CustomEvent) => ((() => this.onChipRemoveActivate(row.value)) as (...args: any[]) => any)($event.detail)}>
<span class="rozie-combobox-chip__label" data-rozie-s-9546115a>${rozieDisplay(row.label)}</span>
<button class="rozie-combobox-chip__remove" type="button" ?disabled=${!!this.disabled} aria-label=${rozieAttr(this.chipRemoveLabel(row))} @mousedown=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { $event.preventDefault(); this.onChipRemovePointerDown(); }} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { $event.stopPropagation(); this.onChipRemoveActivate(row.value); }} data-rozie-s-9546115a>×</button>
</slot>`}
</li>`)}
</ul>` : nothing}<input class="rozie-combobox-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded=${!!this.popupVisible()} aria-controls=${rozieAttr(this.listId())} aria-activedescendant=${rozieAttr(this.activeId())} aria-label=${rozieAttr(this.ariaLabel)} .value=${this._inputText.value} placeholder=${this.placeholder} ?disabled=${!!this.disabled} autocomplete="off" @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInput($event); }} @focus=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onFocus($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onBlur($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} @paste=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onPaste($event); }} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { $event.stopPropagation(); this.onNativeInputChange(); }} data-rozie-ref="inputEl" data-rozie-s-9546115a />
</div>
${this.popupVisible() && !this.virtual && !this.isGrouped() ? html`<ul class="rozie-combobox-list" id=${rozieAttr(this.listId())} role="listbox" aria-multiselectable=${rozieAttr(this.multiple ? 'true' : null)} 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': this.isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" id=${rozieAttr(this.optId(opt._i))} role="option" aria-selected=${!!this.isRowSelected(opt)} aria-disabled=${!!opt.disabled} @mousedown=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { $event.preventDefault(); this.selectOption(opt); }} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { 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: this.isRowSelected(opt), 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: this.isRowSelected(opt), disabled: opt.disabled}); } catch { return '{}'; } })()}>${rozieDisplay(opt.label)}</slot>`}
</li>`)}
${this.filteredOptions().length === 0 && !this.isCreatableQuery() ? html`<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>
${this.empty !== undefined ? this.empty({query: this._inputText.value}) : html`<slot name="empty" data-rozie-params=${(() => { try { return JSON.stringify({query: this._inputText.value}); } catch { return '{}'; } })()}>No results</slot>`}
</li>` : nothing}${this.isCreatableQuery() ? html`<li class="${Object.entries({ "rozie-combobox-create": true, "rozie-combobox-option": true, 'rozie-combobox-option--active': this.filteredOptions().length === this._activeIndex.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" id=${rozieAttr(this.optId(this.filteredOptions().length))} role="option" @mousedown=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { $event.preventDefault(); this.selectOption(this.createRowAt(this.filteredOptions().length)); }} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { this._activeIndex.value = this.filteredOptions().length; }} data-rozie-s-9546115a>
${this.create !== undefined ? this.create({query: this._inputText.value}) : html`<slot name="create" data-rozie-params=${(() => { try { return JSON.stringify({query: this._inputText.value}); } catch { return '{}'; } })()}>Create "${this._inputText.value}"</slot>`}
</li>` : nothing}</ul>` : nothing}${this.popupVisible() && !this.virtual && this.isGrouped() && !this.isCapped() ? html`<ul class="rozie-combobox-list" id=${rozieAttr(this.listId())} role="listbox" aria-multiselectable=${rozieAttr(this.multiple ? 'true' : null)} data-rozie-s-9546115a>
${repeat<any>(this.groupBlocks(), (blk, _idx) => 'grp-' + (blk.group ? blk.group.id : '_ungrouped'), (blk, _idx) => html`<li class="rozie-combobox-group" role="group" aria-label=${rozieAttr(blk.group ? blk.group.label : null)} data-rozie-s-9546115a>
${blk.group ? html`<div class="rozie-combobox-group-heading" role="presentation" data-rozie-s-9546115a>
${this.groupHeading !== undefined ? this.groupHeading({group: blk.group}) : html`<slot name="groupHeading" data-rozie-params=${(() => { try { return JSON.stringify({group: blk.group}); } catch { return '{}'; } })()}>${rozieDisplay(blk.group.label)}</slot>`}
</div>` : nothing}${repeat<any>(blk.items, (opt, _idx) => opt.value, (opt, _idx) => html`<div class="${Object.entries({ "rozie-combobox-option": true, 'rozie-combobox-option--active': opt._i === this._activeIndex.value, 'rozie-combobox-option--selected': this.isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" id=${rozieAttr(this.optId(opt._i))} role="option" aria-selected=${!!this.isRowSelected(opt)} aria-disabled=${!!opt.disabled} @mousedown=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { $event.preventDefault(); this.selectOption(opt); }} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { 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: this.isRowSelected(opt), 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: this.isRowSelected(opt), disabled: opt.disabled}); } catch { return '{}'; } })()}>${rozieDisplay(opt.label)}</slot>`}
</div>`)}
</li>`)}
${this.groupBlocks().length === 0 && !this.isCreatableQuery() ? html`<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>
${this.empty !== undefined ? this.empty({query: this._inputText.value}) : html`<slot name="empty" data-rozie-params=${(() => { try { return JSON.stringify({query: this._inputText.value}); } catch { return '{}'; } })()}>No results</slot>`}
</li>` : nothing}${this.isCreatableQuery() ? html`<li class="${Object.entries({ "rozie-combobox-create": true, "rozie-combobox-option": true, 'rozie-combobox-option--active': this.filteredOptions().length === this._activeIndex.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" id=${rozieAttr(this.optId(this.filteredOptions().length))} role="option" @mousedown=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { $event.preventDefault(); this.selectOption(this.createRowAt(this.filteredOptions().length)); }} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { this._activeIndex.value = this.filteredOptions().length; }} data-rozie-s-9546115a>
${this.create !== undefined ? this.create({query: this._inputText.value}) : html`<slot name="create" data-rozie-params=${(() => { try { return JSON.stringify({query: this._inputText.value}); } catch { return '{}'; } })()}>Create "${this._inputText.value}"</slot>`}
</li>` : nothing}</ul>` : nothing}${this.popupVisible() && !this.virtual && this.isCapped() ? html`<ul class="rozie-combobox-list" id=${rozieAttr(this.listId())} role="listbox" aria-multiselectable=${rozieAttr(this.multiple ? 'true' : null)} data-rozie-s-9546115a>
${repeat<any>(this.cappedBlocks(), (blk, _idx) => 'grp-' + (blk.group ? blk.group.id : '_ungrouped'), (blk, _idx) => html`<li class="rozie-combobox-group" role="group" aria-label=${rozieAttr(blk.group ? blk.group.label : null)} data-rozie-s-9546115a>
${blk.group ? html`<div class="rozie-combobox-group-heading" role="presentation" data-rozie-s-9546115a>
${this.groupHeading !== undefined ? this.groupHeading({group: blk.group}) : html`<slot name="groupHeading" data-rozie-params=${(() => { try { return JSON.stringify({group: blk.group}); } catch { return '{}'; } })()}>${rozieDisplay(blk.group.label)}</slot>`}
</div>` : nothing}${repeat<any>(blk.items, (opt, _idx) => opt.value, (opt, _idx) => html`<div class="${Object.entries({ "rozie-combobox-option": true, 'rozie-combobox-option--active': opt._i === this._activeIndex.value, 'rozie-combobox-option--selected': this.isRowSelected(opt), 'rozie-combobox-option--disabled': opt.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" id=${rozieAttr(this.optId(opt._i))} role="option" aria-selected=${!!this.isRowSelected(opt)} aria-disabled=${!!opt.disabled} @mousedown=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { $event.preventDefault(); this.selectOption(opt); }} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { 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: this.isRowSelected(opt), 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: this.isRowSelected(opt), disabled: opt.disabled}); } catch { return '{}'; } })()}>${rozieDisplay(opt.label)}</slot>`}
</div>`)}
${blk.more ? html`<div class="${Object.entries({ "rozie-combobox-more": true, "rozie-combobox-option": true, 'rozie-combobox-option--active': blk.more._i === this._activeIndex.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" id=${rozieAttr(this.optId(blk.more._i))} role="option" @mousedown=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { $event.preventDefault(); this.selectOption(blk.more); }} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this._activeIndex.value = blk.more._i; }} data-rozie-s-9546115a>
${this.groupMore !== undefined ? this.groupMore({group: blk.group, hidden: blk.more.hidden, expand: blk.more.expand}) : html`<slot name="groupMore" data-rozie-params=${(() => { try { return JSON.stringify({group: blk.group, hidden: blk.more.hidden, expand: blk.more.expand}); } catch { return '{}'; } })()}>+${rozieDisplay(blk.more.hidden)} more</slot>`}
</div>` : nothing}</li>`)}
${this.cappedBlocks().length === 0 && !this.isCreatableQuery() ? html`<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>
${this.empty !== undefined ? this.empty({query: this._inputText.value}) : html`<slot name="empty" data-rozie-params=${(() => { try { return JSON.stringify({query: this._inputText.value}); } catch { return '{}'; } })()}>No results</slot>`}
</li>` : nothing}${this.isCreatableQuery() ? html`<li class="${Object.entries({ "rozie-combobox-create": true, "rozie-combobox-option": true, 'rozie-combobox-option--active': this.cappedRowCount() === this._activeIndex.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" id=${rozieAttr(this.optId(this.cappedRowCount()))} role="option" @mousedown=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { $event.preventDefault(); this.selectOption(this.createRowAt(this.cappedRowCount())); }} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { this._activeIndex.value = this.cappedRowCount(); }} data-rozie-s-9546115a>
${this.create !== undefined ? this.create({query: this._inputText.value}) : html`<slot name="create" data-rozie-params=${(() => { try { return JSON.stringify({query: this._inputText.value}); } catch { return '{}'; } })()}>Create "${this._inputText.value}"</slot>`}
</li>` : nothing}</ul>` : nothing}${this.virtual ? html`<ul class="rozie-combobox-list rozie-combobox-list--virtual" id=${rozieAttr(this.listId())} role="listbox" aria-multiselectable=${rozieAttr(this.multiple ? 'true' : null)} style=${rozieStyle((this.popupVisible() ? '' : '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.windowedView(), (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': this.isRowSelected(wr.row), 'rozie-combobox-option--disabled': wr.row.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" id=${rozieAttr(this.optId(wr.vi.index))} data-index=${rozieAttr(wr.vi.index)} role="option" aria-selected=${!!this.isRowSelected(wr.row)} aria-disabled=${!!wr.row.disabled} @mousedown=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { $event.preventDefault(); this.selectOption(wr.row); }} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { 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: this.isRowSelected(wr.row), 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: this.isRowSelected(wr.row), 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 && !this.isCreatableQuery() ? html`<li class="rozie-combobox-empty" role="presentation" data-rozie-s-9546115a>
${this.empty !== undefined ? this.empty({query: this._inputText.value}) : html`<slot name="empty" data-rozie-params=${(() => { try { return JSON.stringify({query: this._inputText.value}); } catch { return '{}'; } })()}>No results</slot>`}
</li>` : nothing}${this.isCreatableQuery() ? html`<li class="${Object.entries({ "rozie-combobox-create": true, "rozie-combobox-option": true, 'rozie-combobox-option--active': this.windowSource().length === this._activeIndex.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" id=${rozieAttr(this.optId(this.windowSource().length))} role="option" @mousedown=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { $event.preventDefault(); this.selectOption(this.createRowAt(this.windowSource().length)); }} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLLIElement; target: HTMLLIElement }) => { this._activeIndex.value = this.windowSource().length; }} data-rozie-s-9546115a>
${this.create !== undefined ? this.create({query: this._inputText.value}) : html`<slot name="create" data-rozie-params=${(() => { try { return JSON.stringify({query: this._inputText.value}); } catch { return '{}'; } })()}>Create "${this._inputText.value}"</slot>`}
</li>` : nothing}</ul>` : nothing}</rozie-popover>
</div>
`;
}
// ══ 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`).
// - `idRoot()` — the host's id base (Listbox: the `id` prop, else the per-instance id it
// generates in $onMount); `optionId` below derives every option id from it.
// - `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.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 --------------------------------------------------
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;
};
// `idRoot()` is a HOST function (the host's id base: its id prop, else a generated
// per-instance id) so the option ids follow the host's auto-id fallback.
// ══ 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.
// - rowsWindowed(): boolean — is the ROW axis windowed. REQUIRED, no default — replaces every bare
// truthiness read of the host's windowing prop (D-05); `windowedRows()` /
// `padTop()` / `padBottom()` / `rowIsOutsideWindow()` below call it by
// convention exactly as they already call `pinnedEditIndex()`.
// - colsWindowed(): boolean — is the COLUMN axis windowed. REQUIRED, no default. `false` for every
// host until it defines the real column-axis mechanism (87-04+).
// - columnCount(): number — the leaf-column count the column virtualizer windows over. REQUIRED,
// no default.
// - columnSize(i: number): number — the authoritative width of absolute leaf column `i`, sourced
// from table-core's `getSize()` under D-06. REQUIRED, no default.
// - forcedColumns(): number[] — the D-10 OPTIONAL column-axis mirror of `pinnedEditIndex()`: the
// DataTable host unions pinned + active-cell + editing column indices into
// the column-window slice; listbox/combobox pass an empty array (host-
// provided, defaulting to `[]`).
// - colVirtualizer — the host's SECOND virtual-core instance, windowing the COLUMN axis
// (see the AXIS MECHANISM note below). Host-provided, defaulting to `null`.
// - autoMeasureOn(): boolean — the D-18 REQUIRED content-driven-estimate gate (Phase 87 87-07):
// data-table's real body reads `$props.autoMeasure === true`; listbox/
// combobox/command-palette return `false` so the accumulator branch
// estimateRowSize() gates on is dead code for them (D-20).
// - afterRowRemeasure — OPTIONAL host-owned mutable `let` (defaults to a no-op / undefined),
// assigned to refineRowEstimate() (below) by the host. The DataTable
// host's remeasureWindow() (virtualization.rzts) calls it AFTER its
// measureElement sweep so the fold + hysteresis re-feed run on every
// window commit. Routed through a mutable `let` rather than a direct
// call FROM virtualization.rzts INTO this file: a relative-partial CONST
// calling a bare-specifier-partial CONST is the exact forward-reference
// TDZ class remeasureColumnWindow()'s own DataTable.rozie comment
// documents for columnVirtualizerOptions() (inlineScriptPartials()
// groups the relative partial BEFORE the bare-specifier partial in the
// merged per-target output, regardless of source import order). A
// mutable `let` hoists to `useRef` on React and is excluded from a
// useCallback's dependency array, sidestepping the hazard entirely — the
// SAME mechanism `refreshRowModel` already relies on.
//
// AXIS MECHANISM (OQ1 / Assumption A1 — resolved from the installed source in 87-02;
// LANDED in 87-04: `columnVirtualizerOptions()` below IS the second, horizontal instance this
// note originally only documented). `horizontal` is a PER-INSTANCE field of `VirtualizerOptions`
// (`node_modules/@tanstack/virtual-core/dist/esm/index.d.ts:67`, installed version 3.17.1 per
// `package.json`), and every axis-sensitive internal read consults `instance.options.horizontal` —
// `measureElement`'s inlineSize/blockSize + offsetWidth/offsetHeight branch
// (`dist/esm/index.js:137,150`), `observeElementOffset`'s scrollLeft/scrollTop branch
// (`dist/esm/index.js:118-121`), `getMaxScrollOffset`'s scrollWidth/scrollHeight branch
// (`dist/esm/index.js:907-915`), and `scrollWithAdjustments`'s left/top branch
// (`dist/esm/index.js:152-161`). So ONE `Virtualizer` instance windows exactly ONE axis: the column
// axis needs its own SECOND, independent `Virtualizer` instance constructed with `horizontal: true`,
// sharing the SAME `getScrollElement()` (the `rdt-scroll` wrapper) the row instance already uses.
// Two options the row axis does not set that the column instance will need: `isRtl?: boolean`
// (data-table ships an RTL grid path) and `overscan?: number` (D-07 gives the column axis its own
// hardcoded constant, separate from the row axis's `overscan: 8` below).
//
// isRtl WIRING (gap-closure 87-09, LANDED — see `ensureColRtlWatch()`/`isColRtl()` below,
// immediately ahead of `columnVirtualizerOptions()`): data-table has no construction-time RTL
// signal (no `dir`/`rtl` prop), and `dir` can be set on `gridScrollEl` at ANY point relative to
// mount. `isRtl` is therefore computed LIVE via `getComputedStyle`, not baked in once.
// 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).
virtualItemKey = (i: any) => {
const src = this.windowSource();
return src && src[i] ? src[i].id : undefined;
};
// COL_OVERSCAN (D-07): the column axis's own hardcoded overscan constant, separate from the
// row axis's `overscan: 8` below. Columns are far wider than rows are tall, so one number
// cannot serve both axes; no prop is exposed because no consumer has asked to tune the row
// overscan across the four phases it has shipped. Unused until 87-04 constructs the second,
// horizontal Virtualizer instance (see the AXIS MECHANISM note above).
// ══ Phase 87 87-07 (D-15/D-18) — content-driven auto-measure: the shared engine's FIRST
// mutable top-level state. Hoisted to `useRef` PER-INSTANCE by the React emitter's
// hoistModuleLet — the SAME mechanism already load-bearing for `table`, `virtualizer`,
// `remeasurePending`, and `gridScrollEl` in the DataTable host (Task 1's confirmed
// precedent), so two DataTable instances on one page never share an accumulator
// (T-87-07-04). measuredRowTotal/measuredRowCount together give the running MEAN of every
// row folded in so far; lastFedRowEstimate is the estimate value most recently pushed into
// virtual-core (the hysteresis comparison baseline). ══
measuredRowTotal = 0;
measuredRowCount = 0;
// ══ Gap-closure 87-10 — windowVerBumpPending / bumpWindowVer(): coalesce EVERY $data.windowVer
// write behind a SINGLE microtask-deferred increment, regardless of how many callers request
// one within the same synchronous JS task. ══
//
// ROOT CAUSE (framework-agnostic; the Solid-specific symptom this closes only EXPOSES it) —
// confirmed by instrumenting the installed @tanstack/virtual-core@3.17.1 source directly
// (dist/esm/index.js), not by reasoning abstractly: virtual-core's resizeItem() calls
// `this.notify(false)` — synchronously invoking `virtualizerOptions().onChange` below — EVERY
// TIME a measured row's real size differs from its cached one (`delta !== 0`), independent of
// framework (dist/esm/index.js:836-874). remeasureWindow()'s CR-01 sweep
// (packages/ui/data-table/src/virtualization.rzts) measures EVERY currently-rendered `<tr>` in
// ONE for-loop BEFORE calling afterRowRemeasure() (refineRowEstimate() below) — so a single
// synchronous JS task (e.g. the very first measurement pass, which transitions N never-before-
// measured rows from the flat seed to their real heights) can fire onChange, and therefore an
// UNCOALESCED `$data.windowVer = $data.windowVer + 1`, MANY times in a row — well BEFORE
// refineRowEstimate()'s own fold-then-re-feed (which runs only AFTER that loop finishes) has
// folded those same measurements into the running mean or re-fed the converged estimate into
// virtual-core via setOptions(). A live trace of this exact sequence (instrumented resizeItem/
// getMeasurements calls, DataTableColumnVirtualDemo, autoMeasure on) showed Vue batching 3
// resizeItem calls before its ONE downstream re-render reads getMeasurements() — already
// reflecting the fully-folded, re-fed state — versus Solid re-running its padTop()/padBottom()
// effects SYNCHRONOUSLY and IMMEDIATELY on EVERY individual windowVer write (11 interleaved
// resize-then-immediate-recompute pairs, each recompute happening mid-sweep, before
// refineRowEstimate() had run even once). React/Vue/Svelte/Angular/Lit all batch their own
// reactivity to at least a microtask boundary, so their downstream reads land AFTER the whole
// synchronous burst (measurement sweep + fold + re-feed) completes — accidentally correct, not
// correct by construction. Solid does not auto-batch a signal write made from outside a
// Solid-owned event/effect context, so it is the one target where the mid-burst TORN read is
// externally observable. Because `setOptions()` + `_willUpdate()` alone do NOT invalidate
// virtual-core's own `getMeasurements()` memo (keyed on itemSizeCacheVersion /
// getMeasurementOptions() — never on the estimateSize FUNCTION reference itself; confirmed from
// the same installed source, dist/esm/index.js:585-587,624), Solid's LAST such mid-sweep
// recompute is also the LAST time getMeasurements() is ever invoked for that sweep once no
// further row happens to differ from its cache — so the DOM stays frozen on that stale,
// pre-fold/pre-re-feed snapshot indefinitely, even though the accumulator itself has already
// converged correctly (T-87-07's own confirmed finding).
//
// FIX: coalesce every requester of a windowVer bump — virtual-core's own onChange AND
// refineRowEstimate()'s explicit re-feed bump — behind ONE microtask-deferred write, the SAME
// idiom scheduleRemeasure() already uses in virtualization.rzts. This makes the render happen
// EXACTLY ONCE, strictly AFTER the entire synchronous burst (including refineRowEstimate()'s
// fold + re-feed) on EVERY target, by construction rather than by incidental host-framework
// batching. Scoped to the ROW axis only: colVirtualizer never calls resizeItem() at all (D-06 —
// column widths come from table-core's getSize() oracle, never measured from the DOM), so
// columnVirtualizerOptions()'s onChange cannot hit this burst class and is left untouched.
windowVerBumpPending = false;
bumpWindowVer = (): void => {
if (this.windowVerBumpPending) return;
this.windowVerBumpPending = true;
const flush = () => {
this.windowVerBumpPending = false;
this._windowVer.value = this._windowVer.value + 1;
};
// Mirrors scheduleRemeasure()'s own defensive queueMicrotask-with-setTimeout-fallback
// (virtualization.rzts) for environments where queueMicrotask is unavailable.
if (typeof queueMicrotask !== 'undefined') queueMicrotask(flush);else setTimeout(flush, 0);
};
// ESTIMATE_REFEED_DELTA_PX (D-15): the hysteresis threshold gating a re-feed into
// virtual-core. Without it, a mean nudging by a fraction of a pixel on every fold would
// re-feed on every window commit — the T-87-07-01 DoS control, paired with virtual-core's
// own measureElement/resizeItem idempotence (see refineRowEstimate() below).
// estimateRowSize(i) (D-15/D-17): the estimateSize() resolver. MUST check !autoMeasureOn()
// FIRST so the off path touches zero accumulator state and returns $props.estimateRowHeight
// verbatim (D-17's byte-behavioral no-op). The zero-measurements case (first paint,
// regardless of autoMeasure) still returns the seed — the very first render has nothing
// measured yet either way (D-15).
estimateRowSize = (i: number): number => {
if (!this.autoMeasureOn()) return this.estimateRowHeight;
if (this.measuredRowCount === 0) return this.estimateRowHeight;
return Math.round(this.measuredRowTotal / this.measuredRowCount);
};
// foldMeasuredRow(index, height): fold ONE measured row's height into the running-mean
// accumulator, UPDATING (not double-adding) an already-folded index (T-87-07-03).
// 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's windowVer write is routed through
// bumpWindowVer() (87-10) rather than a raw `$data.x = $data.x + 1` — resizeItem() can call
// this onChange MANY times in a single synchronous sweep (once per row whose real measured
// size differs from its cache, e.g. every never-before-measured row in the FIRST window),
// and coalescing those into one microtask-deferred write is what keeps every target's render
// landing strictly AFTER the whole sweep (see bumpWindowVer()'s own comment for the confirmed
// Solid-specific rendering gap this closes). The React emitter still lowers the underlying
// `$data.windowVer = $data.windowVer + 1` to functional setState — correct even deferred to a
// microtask, exactly as it was correct from a mount closure before.
virtualizerOptions = (): any => ({
count: this.windowSource().length,
getScrollElement: () => this.gridScrollEl,
estimateSize: (i: any) => this.estimateRowSize(i),
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: this.virtualItemKey,
onChange: () => {
this.bumpWindowVer();
// 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): 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: number): {
start: number;
size: number;
index: number;
end: number;
} | null => this.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 = () => {
// 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) {
// Rows OFF (Phase 87 D-04: this now includes the colsWindowed()-only path, since the
// wrapper template is entered whenever isWindowed(), not just rowsWindowed() — the row
// virtualizer is never constructed when only the column axis is windowed, D-04) → the FULL
// set, with a SYNTHETIC `vi.index` set to each row's array position (matching rowIndexOf's
// own `$data.rows.indexOf(row)` semantics exactly, since $data.rows IS windowSource()'s
// output here). Every windowed body binding reads wr.vi.index (data-row, aria-rowindex,
// colIndexOf, isEditing, the fill handle) — a bare `null` there is a hard crash the moment
// this branch is reached with the wrapper mounted, which colsWindowed()-only now does.
// Row-virtual ON but the virtualizer is not yet constructed (pre-$onMount first paint) →
// render NOTHING so the template never dereferences a not-yet-real `vi`; the rows appear on
// the first onChange after _didMount.
if (!this.rowsWindowed()) {
const rowList = this._rows.value || [];
return rowList.map((r: any, i: any) => ({
vi: {
index: i
},
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;
};
// 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.
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.rowsWindowed() || !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.rowsWindowed() || !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: is full-model index `idx` present in the rendered virtual window?
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: any) => {
if (!this.rowsWindowed() || !this.virtualizer) return false;
const items = this.virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
// ══ Phase 87 87-04 — the column-axis analogs of windowedRows()/padTop()/padBottom()/
// rowIsOutsideWindow() above. The column axis has no "row-shaped" identity to carry alongside
// a VirtualItem (a column is not a full-model object the way a row is), so windowedColIndices()
// returns bare ABSOLUTE leaf-column indices; the template resolves each index back to a header/
// cell through the host's own header-group / visibleCellsFor lookups (D-08/D-09). ══
// windowedColIndices(): the ordered array of ABSOLUTE leaf-column indices to render.
virtualizer: any = null;
virtualizerCleanup: any = null;
gridScrollEl: any = null;
remeasurePending = false;
// Scroll-end pin state (see recordScrollEnd()): whether the USER left the view at the end,
// the option count at that moment, and the last scrollTop already accounted for.
scrollEndPinned: boolean = false;
scrollEndPinnedCount: number = -1;
scrollEndPinnedTop: number = -1;
// Non-reactive per-instance flag (Phase 86 R2, plan 86-03, Solid-only): true for
// the duration of an onFocus-triggered open transition (set before the isOpen
// write, cleared in the deferred microtask after). Lets onBlur distinguish a
// blur caused by Solid recreating the anchor's DOM mid-open (skip closing) from
// a genuine user-initiated blur (close normally). See onFocus/onBlur below.
openingInProgress = false;
// Non-reactive per-instance flag (combobox-virtual-reactivity phase): set true once
// $onMount has run; read by windowedView() below so the blank-frame fallback (D-4) only
// fires on a genuine RUNTIME flip — a virtual:true-at-mount (never-flipped) consumer's
// first paint stays byte-stable (windowedRows()'s own pre-mount `[]` still applies before
// didMount flips true). Mirrors the same write-in-$onMount/read-elsewhere holder class.
didMount = 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.
//
// Quick 260717-8zb dogfood: re-expressed on the `$memo(fn, keyFn)` primitive.
// `$memo` lowers (core, shared across all 6 targets) to a member-mutated
// fresh-object cache const + a wrapper function — EXACTLY this foCache shape,
// generalized. On React the emitted cache const is stabilized to
// `useMemo(() => ({…}), [])` by the EXISTING collectMutatedInstanceBinders/
// tryWrapMutatedInstanceUseMemo machinery (feedback_react_const_mutinstance_
// not_stabilized) — no per-target $memo code. On the 5 setup-once targets the
// top-level consts persist for the instance lifetime naturally.
//
// keyFn is the SUBSCRIBE-FIRST half (fine-grained Solid <For> / Svelte
// {#each}): it reads ALL FOUR reactive inputs UNCONDITIONALLY — $data.inputText
// even when disableFilter is true (mirrors windowing.rzts windowedRows
// void-touch discipline) and $props.groups even when $props.virtual (so a
// groups change while windowed still invalidates the cache once virtual
// toggles off) — evaluated BEFORE $memo's cache-hit check, so the r-for
// accessor subscribes to them on every eval. 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 / groups-ref) change.
//
// fn is the MISS path (unchanged from the hand-rolled foCache): run the
// filter, then (native option grouping, combobox-native-groups) a
// NON-VIRTUAL-ONLY stable re-partition into group-visual order, then map to
// wrapper rows.
filteredOptionsCache = {
keys: null as any[] | null,
val: null as any
};
filteredOptions = () => {
const __rozieMemoKey = (() => {
const opts = Array.isArray(this.options) ? this.options : [];
const df = !!this.disableFilter;
const q = String(this._inputText.value == null ? '' : this._inputText.value);
const groupsProp = this.groups;
return [opts, q, df, groupsProp];
})();
const __rozieMemoPrev = this.filteredOptionsCache.keys;
if (__rozieMemoPrev !== null && __rozieMemoPrev.length === __rozieMemoKey.length && __rozieMemoKey.every((v: any, i: any) => v === __rozieMemoPrev[i])) {
return this.filteredOptionsCache.val;
}
const __rozieMemoVal = (() => {
const opts = Array.isArray(this.options) ? this.options : [];
const df = !!this.disableFilter;
const q = String(this._inputText.value == null ? '' : this._inputText.value);
const groupsProp = this.groups;
let list = opts;
if (!df) {
const ql = q.toLowerCase();
if (ql) list = opts.filter((o: any) => String(this.labelOf(o)).toLowerCase().indexOf(ql) !== -1);
}
// Gated to !$props.virtual (groups×virtual is deferred/unsupported per design) AND to
// $props.groups being a NON-EMPTY array — an explicit author opt-in. This is deliberately
// NOT just "!$props.virtual" (groupOptions() would otherwise also fire whenever any raw
// option happens to carry a `.group` field, even with `groups` absent — a real collision
// discovered against command-palette's CommandItem.group, which is a PRE-EXISTING,
// unrelated per-row-badge field, not an opt-in to combobox's native grouping. The design's
// "Empty/absent `groups` ⇒ today's flat behavior, byte-identical" contract is about the
// `groups` PROP only — never inferred from incidental option shape.
if (!this.virtual && Array.isArray(groupsProp) && groupsProp.length > 0) {
const partition = groupOptions(list, groupsProp, (o: any) => o && o.group != null ? String(o.group) : null);
list = partition.ordered;
}
// `_i` is assigned over the (now group-ordered) list, so the flat keyboard model
// (activeIndex/aria-activedescendant/nextEnabled) walks visual order unchanged.
// `group` carries the wrapper's normalized group id for groupBlocks() below.
return 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,
group: o && o.group != null ? String(o.group) : null
}));
})();
this.filteredOptionsCache.keys = __rozieMemoKey;
this.filteredOptionsCache.val = __rozieMemoVal;
return __rozieMemoVal;
};
// 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 = () => this.filteredOptions();
// windowedView() (combobox-virtual-reactivity, VIRT-FALLBACK): the combobox-side
// blank-frame fallback for the mid-flip frame. While `virtual` is on but the virtualizer
// has not yet (re)attached (didMount-gated, so the never-flipped virtual:true-at-mount
// first paint is untouched — windowedRows()'s own pre-mount `[]` still governs it),
// render the UN-WINDOWED full windowSource() slice mapped to the `{ vi: { index }, row }`
// shape the windowed template consumes (`wr.vi.index` resolves to the wrapper's own `_i`,
// since windowSource() IS the filtered/indexed list navRows()/activeIndex already walk).
// Once the virtualizer is built, delegates to windowedRows() UNCHANGED — byte-identical
// to today's steady windowed state. Entirely combobox-side: @rozie-ui/headless-core/
// windowing.rzts is untouched, preserving data-table's B13 A==B byte-identity + its
// empty-diff regen.
windowedView = () => {
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}) — touch windowVer at the
// TOP, mirroring windowedRows()'s own subscribe-first discipline (windowing.rzts), so
// the accessor re-runs when buildVirtualizer()/kickWindow() bump windowVer once the
// virtualizer attaches — the transition OUT of this fallback and into windowedRows().
void this._windowVer.value;
if (this.virtual && !this.virtualizer && this.didMount) {
return this.windowSource().map((row: any) => ({
vi: {
index: row._i
},
row
}));
}
return this.windowedRows();
};
// ---- native option grouping render helpers (combobox-native-groups) ---------------
// groupBlocks(): re-partition the ALREADY group-ordered filteredOptions() wrappers into
// CONTIGUOUS runs by wrapper.group (trivial + guarantees `_i` alignment, since `ordered`
// from groupOptions() is already group-contiguous). Attaches each run's `{ id, label }`
// from $props.groups (fallback label = the group id itself). Plain function — never
// $computed (mirrors filteredOptions()'s convention). Non-virtual only (isGrouped() below
// already gates the template branch that calls this).
groupBlocks = () => {
const wrappers = this.filteredOptions();
const groupsProp = Array.isArray(this.groups) ? this.groups : [];
const labelFor = (gid: any) => {
const found = groupsProp.find((g: any) => g && g.id === gid);
return found ? found.label : gid;
};
const blocks = [];
let lastGid;
for (let i = 0; i < wrappers.length; i++) {
const w = wrappers[i];
if (i === 0 || w.group !== lastGid) {
blocks.push({
group: w.group == null ? null : {
id: w.group,
label: labelFor(w.group)
},
items: [w]
});
} else {
blocks[blocks.length - 1].items.push(w);
}
lastGid = w.group;
}
return blocks;
};
// isGrouped(): the grouped-vs-flat template branch selector. Grouping is active
// (non-virtual only) SOLELY when the author explicitly set a non-empty `groups` prop —
// deliberately NOT "OR any option carries a group" (a real collision discovered against
// command-palette's pre-existing CommandItem.group per-row-badge field; see the
// filteredOptions() comment above). Mirrors that same non-empty-`groups` gate exactly, so
// isGrouped() and the filteredOptions() partition never disagree about which branch is active.
isGrouped = () => !this.virtual && Array.isArray(this.groups) && this.groups.length > 0;
// ---- per-group result cap + expand-in-place "+N more" (combobox-group-cap) --------
// capNum(): coerce $props.groupCap to a whole, positive cap; anything else (NaN,
// negative, absent) degrades to 0 (uncapped). Plain function — never $computed.
capNum = () => {
const n = Number(this.groupCap);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
};
// isCapped(): the capped-render branch selector. isGrouped() already gates non-
// virtual + non-empty `groups`, so the cap is automatically gated OUT of the
// virtual and ungrouped paths.
isCapped = () => this.isGrouped() && this.capNum() > 0;
// gkey(gid): normalize a group id (possibly null, for the leading ungrouped
// section) into an expandedGroups map key.
gkey = (gid: any) => gid == null ? '__ungrouped__' : String(gid);
// isExpanded(gid): whether the group has been expanded via its "+N more" row.
isExpanded = (gid: any) => !!(this._expandedGroups.value && this._expandedGroups.value[this.gkey(gid)]);
// expandGroup(gid): replace $data.expandedGroups IMMUTABLY (load-bearing for
// React re-render — feedback_react_const_mutinstance_not_stabilized / the
// graph-writeback immutability rule).
expandGroup = (gid: any) => {
this._expandedGroups.value = Object.assign({}, this._expandedGroups.value, {
[this.gkey(gid)]: true
});
};
// cappedBlocks(): the visible-block model for the capped render — groupBlocks()
// re-sliced to `capNum()` per group (unless expanded or non-overflowing), with a
// trailing "+N more" row appended to any still-capped block. Re-indexes `_i` as a
// running counter over the WHOLE visible+more sequence so option ids/aria-
// activedescendant stay contiguous and never disagree with navRows() below.
cappedBlocks = () => {
const blocks = this.groupBlocks();
const cap = this.capNum();
let running = 0;
const out = [];
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
const gid = blk.group ? blk.group.id : null;
const showAll = this.isExpanded(gid) || blk.items.length <= cap;
const visibleSrc = showAll ? blk.items : blk.items.slice(0, cap);
const items = [];
for (let vi = 0; vi < visibleSrc.length; vi++) {
items.push(Object.assign({}, visibleSrc[vi], {
_i: running
}));
running++;
}
let more: any = null;
if (!showAll) {
more = {
isMore: true,
group: gid,
hidden: blk.items.length - cap,
disabled: false,
_i: running,
expand: () => this.expandGroup(gid)
};
running++;
}
out.push({
group: blk.group,
items,
more
});
}
return out;
};
// ---- creatable mode (Phase 86 R3, D-17..D-20) ---------------------------
// normalizedQuery(): trimmed + lower-cased query — reuses the SAME case-fold
// filteredOptions() already applies above, but for an EXACT-EQUALITY
// comparison, never a substring search, and with NO Unicode normalization
// (R3 locked: a composition-form difference must NOT be treated as a match).
normalizedQuery = () => String(this._inputText.value == null ? '' : this._inputText.value).trim().toLowerCase();
// queryMatchesOption(nq): whether the (already-normalized) query is an exact,
// case-insensitive, trimmed match of some option's label.
queryMatchesOption = (nq: any) => {
const opts = Array.isArray(this.options) ? this.options : [];
return opts.some((o: any) => String(this.labelOf(o)).trim().toLowerCase() === nq);
};
// isCreatableQuery(): the create-row visibility gate (also gates the `#empty`
// -> `#create` swap, D-19). `creatable` must be set, the normalized query
// must be non-empty (an empty/whitespace-only query never offers create —
// `#empty` keeps its job there), and no option's normalized label may equal
// it exactly.
isCreatableQuery = () => {
if (!this.creatable) return false;
const nq = this.normalizedQuery();
if (!nq) return false;
return !this.queryMatchesOption(nq);
};
// createRowAt(baseCount): the synthetic, non-option `role="option"` create
// row (D-17) — mirrors the `groupMore` "+N more" row shape exactly (a real
// id, arrow-reachable, commits through the SAME selectOption() dispatch
// without writing the model). Each render branch passes ITS OWN flattened
// pre-create-row row count (`baseCount`) as the running index, exactly as
// `cappedBlocks()` already re-indexes `_i` across options + the more row —
// so ids / aria-activedescendant / navRows() can never disagree.
createRowAt = (baseCount: any) => ({
isCreate: true,
_i: baseCount,
disabled: false
});
// cappedRowCount(): the total navigable row count cappedBlocks() flattens to
// (visible items + more-rows, across every block) — the running index the
// capped branch's own create row (below) must continue from. Mirrors
// cappedBlocks()'s own `running` counter without re-deriving `_i` per item.
cappedRowCount = () => {
const blocks = this.cappedBlocks();
let n = 0;
for (let bi = 0; bi < blocks.length; bi++) {
n += blocks[bi].items.length;
if (blocks[bi].more) n++;
}
return n;
};
// navRows(): the SINGLE keyboard/aria source of truth. Returns the EXACT
// filteredOptions() reference when not capped and not creatable (byte-
// identical-off — untouched virtual/ungrouped keyboard path); flattens
// cappedBlocks() into visible items + more-rows, in order, when capped.
// Appends the create row, AFTER the full flattened visible(+more) sequence,
// whenever isCreatableQuery() — R3's locked "renders last, after all options
// and group sections" is a positional fact here, not a per-branch special case.
navRows = () => {
if (!this.isCapped()) {
const base = this.filteredOptions();
if (!this.isCreatableQuery()) return base;
return base.concat([this.createRowAt(base.length)]);
}
const out = [];
const blocks = this.cappedBlocks();
for (let bi = 0; bi < blocks.length; bi++) {
const blk = blocks[bi];
for (let ii = 0; ii < blk.items.length; ii++) out.push(blk.items[ii]);
if (blk.more) out.push(blk.more);
}
if (this.isCreatableQuery()) out.push(this.createRowAt(out.length));
return out;
};
// 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.
pinnedEditIndex = () => -1;
pinnedMeasurement = (pin: any) => null;
// D-05 windowing.rzts host-contract one-liner (Phase 87 87-02). rowsWindowed() preserves
// today's EXACT truthiness (byte-behavior-identical) — it is the REQUIRED symbol
// windowing.rzts calls in place of a bare `$props.virtual` read.
//
// GAP-CLOSURE 87-16 (WR-02): the column-axis host-contract symbols (`colVirtualizer`,
// `colsWindowed()`, `columnCount()`, `columnSize()`, `forcedColumns()`) that 87-02 added
// alongside this were REMOVED here — they were dead code shipped on a mistaken premise
// about the compiler's tree-shaking BFS. Combobox imports only `{ virtualItemKey,
// virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow }`
// from windowing.rzts; none of those functions' bodies reference the column-axis symbols
// (only `columnVirtualizerOptions()`/`windowedColIndices()`/`colPadLeft()`/`colPadRight()`/
// `colIsOutsideWindow()` do, and Combobox never imports any of those), so
// `inlineScriptPartials()`'s BFS never needed them to exist. See 87-REVIEW.md WR-02 /
// 87-16-SUMMARY.md for the verification trail.
rowsWindowed = () => !!this.virtual;
// autoMeasureOn() (Phase 87 87-07, D-18/D-20): the content-driven-estimate host-contract
// gate. Combobox never lights this branch — a permanent `false` keeps windowing.rzts's
// estimateRowSize()/refineRowEstimate() accumulator dead code here. RETAINED (unlike the
// column-axis symbols above): `virtualizerOptions()` — which Combobox DOES import and call
// — wires `estimateSize: (i) => estimateRowSize(i)`, and `estimateRowSize()` calls
// `autoMeasureOn()` as its first line. This one IS reachable through the import graph.
autoMeasureOn = (): boolean => false;
// Keep $data.rows === windowSource() so the windowing math indexes the live filtered set.
syncRows = () => {
this._rows.value = this.windowSource();
};
// SCROLL-END PIN (the data-table D-19 twin, shared shape with Listbox): keep a user who
// scrolled to the END of a variable-height list at the end while the options in view measure
// taller than their estimate. The view is judged on the DOM, and only at a move the USER
// made — a move is virtual-core's own when it still holds an unreconciled scroll adjustment
// (scrollAdjustments !== 0): its above-viewport compensation writes an ABSOLUTE scrollTop
// computed from its last-observed (stale) offset, so it pulls the view back up from the end
// and must neither clear the pin nor be mistaken for the user leaving the end. That position
// is remembered so the scroll event that later reports it is not read as a user move either.
// (Judging on virtual-core's MODEL, as the data-table host does, fails here: its total grows
// with every option measured in the ResizeObserver batch while its offset stays at the stale
// value, so the pin was cleared mid-batch — every target ended 10-126px short, measured.)
recordScrollEnd = () => {
if (!this.virtualizer || !this.gridScrollEl || this.virtualizer.scrollState) return;
const top: number = this.gridScrollEl.scrollTop;
if (top === this.scrollEndPinnedTop) return;
this.scrollEndPinnedTop = top;
if (this.virtualizer.scrollAdjustments !== 0) return;
// Only a list that actually overflows has an end to hold: while the window has not painted
// yet (or the list is closed), scrollHeight <= clientHeight reads as "at the end" and a pin
// recorded then would jump the freshly opened list to the bottom.
const sh = this.gridScrollEl.scrollHeight;
const ch = this.gridScrollEl.clientHeight;
this.scrollEndPinned = ch > 0 && sh - ch > 1 && sh - top - ch <= 1;
this.scrollEndPinnedCount = this.windowSource().length;
};
// Re-apply the pin after the framework has committed the window (called from the rAF pass):
// the real maximum is known only then. Not while a programmatic scroll (scrollToIndex) is in
// flight, and not when the option count changed since the user reached the end (a new query
// or appended options must not be auto-followed).
keepScrollEnd = () => {
if (!this.scrollEndPinned || !this.virtualizer || !this.gridScrollEl || this.virtualizer.scrollState) return;
if (this.windowSource().length !== this.scrollEndPinnedCount) return;
const maxTop: number = this.gridScrollEl.scrollHeight - this.gridScrollEl.clientHeight;
if (maxTop - this.gridScrollEl.scrollTop > 1) {
this.gridScrollEl.scrollTop = maxTop;
this.scrollEndPinnedTop = this.gridScrollEl.scrollTop;
}
};
// 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.
scheduleRemeasure = () => {
this.recordScrollEnd();
if (this.remeasurePending) return;
this.remeasurePending = true;
let ranMicro = false;
const microPass = () => {
this.remeasureWindow();
};
// N-05 (quick 260923-rrr): key the rAF pass on the OUTCOME. React and Angular commit the
// recycled window AFTER the first rAF, so one pass measured the OLD options and the new ones
// waited for virtual-core's 150ms scrolling-ended tick — with variable-height options the late
// above-viewport adjustment then moved the whole list (measured). Re-run next frame until the
// committed options cover the virtualizer's window, bounded (the data-table host twin).
let rafAttempts = 0;
const rafPass = () => {
const covered = this.remeasureWindow();
rafAttempts = rafAttempts + 1;
if (!covered && rafAttempts < 10 && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(rafPass);
return;
}
this.keepScrollEnd();
this.remeasurePending = false;
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.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.
remeasureWindow = () => {
if (!this.virtualizer || !this.gridScrollEl) return true;
if (this.virtualizer.scrollState) return true;
const els = this.gridScrollEl.querySelectorAll('.rozie-combobox-option[data-index]');
const rendered = new Set();
for (const el of els as any) {
this.virtualizer.measureElement(el);
rendered.add(el.getAttribute('data-index'));
}
// N-05: false while the framework has not yet committed the recycled window.
const items = this.virtualizer.getVirtualItems();
for (let i = 0; i < items.length; i++) {
if (!rendered.has(String(items[i].index))) return false;
}
return true;
};
// Keep the active option visible inside the popup. When windowing, route through the
// virtualizer (scrollToIndex) so an active option OUTSIDE the rendered window scrolls
// into view (the windowed-arrow-nav seam). When NOT windowing, resolve the active
// option element directly (a within-own-shadow query, Lit-safe) and scrollIntoView it
// with 'nearest' block alignment — a plain long list taller than the popup's
// max-height must also keep the active option visible during arrow navigation.
scrollActiveIntoView = () => {
if (!this.virtual && this._isOpen.value && this._activeIndex.value >= 0) {
const list = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rozie-combobox-list') : null;
const opt = list ? list.querySelector('#' + this.optId(this._activeIndex.value)) : null;
if (opt) opt.scrollIntoView({
block: 'nearest'
});
return;
}
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();
};
// idRoot(): the id base — the `idBase` prop, else the per-instance id generated
// in $onMount (`autoId`), else the pre-mount fallback. Generated after mount (not
// during setup) so a server render and the hydrating client agree.
idRoot = () => this.idBase || this._autoId.value || 'rozie-combobox';
optId = (i: any) => this.idRoot() + '-opt-' + i;
listId = () => this.idRoot() + '-list';
// popupVisible() (hideEmpty, COMBOBOX-SPEC item 4): whether the popup is actually
// SHOWN — open AND (unless `hideEmpty`) something to render. With `hideEmpty` an
// open popup with no option rows AND no create row counts as hidden: the list
// branches do not render, aria-expanded reports false, and Escape is left to the
// host (B4). Without `hideEmpty` this is exactly `$data.isOpen` (byte-identical-off).
popupVisible = () => {
if (!this._isOpen.value) return false;
if (!this.hideEmpty) return true;
return this.navRows().length > 0;
};
// The active option's id for aria-activedescendant (null when none).
activeId = () => {
const list = this.navRows();
if (this.popupVisible() && this._activeIndex.value >= 0 && list[this._activeIndex.value]) return this.optId(this._activeIndex.value);
return null;
};
// activeOption() (handle verb, COMBOBOX-SPEC item 8): the highlighted RAW source
// option, or null (nothing highlighted, the popup is hidden, or the highlighted
// row is a synthetic "+N more" / create row).
activeOption: () => any = () => {
const list = this.navRows();
const ai = this._activeIndex.value;
if (!this.popupVisible() || ai < 0) return null;
const row = list[ai];
if (!row || row.isMore || row.isCreate) return null;
return row.option === undefined ? null : row.option;
};
// Next selectable index in `dir` (+1/-1), skipping disabled, clamped to ends.
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;
};
// ---- multi-select membership + effective-default helpers (Phase 86 R1) -----
// Ported from @rozie-ui/headless-core/listCore.rzts's select()/isSelected()
// algorithm (also shipped, verbatim, via @rozie-ui/listbox) — PORTED, not
// imported: combobox's own open/active/query state machine is deliberately
// host-local (see the header comment above), and listCore.rzts is also
// consumed by the release-ignored listbox family, so pulling this into the
// shared partial would put listbox's frozen leaves back in scope.
//
// selectedValues(): the current selection as a de-duplicated array, tolerant
// of a null/undefined model. De-duplicates the MODEL array itself (not just
// `options`) so a re-normalized selection never reports the same value twice
// even if the model ever ends up holding a duplicate.
selectedValues = () => {
const cur = this.value;
const arr = Array.isArray(cur) ? cur : [];
return Array.from(new Set(arr));
};
// isRowSelected(row): array membership under `multiple`, strict equality
// otherwise. Replaces every raw `opt.value === $props.value` / `wr.row.value
// === $props.value` template comparison (task 2) so all four render branches
// share exactly ONE membership check and can never disagree.
isRowSelected = (row: any) => {
if (!row) return false;
if (this.multiple) return this.selectedValues().indexOf(row.value) !== -1;
return row.value === this.value;
};
// effectiveCloseOnSelect(): resolves the `closeOnSelect` sentinel (see the
// prop's own doc comment above for why the prop's default is `null`, not a
// literal `true`). Unset ⇒ `true` in single-select (today's default,
// unchanged), `false` under `multiple`; an explicit `true`/`false` from the
// consumer always wins in either mode. Every existing `closeOnSelect` read
// routes through this helper so the four render branches cannot disagree.
effectiveCloseOnSelect = () => {
const v = this.closeOnSelect;
if (v === true || v === false) return v;
return !this.multiple;
};
// chipsInline() (chipLayout, COMBOBOX-SPEC item 2): chips + input on one
// wrapping row — only meaningful under `multiple`.
chipsInline = () => !!this.multiple && this.chipLayout === 'inline';
// ---- chip rail (Phase 86 R1, plan 86-05, D-13/D-16/D-18) ---------------
// chipRows(): selectedValues() (already de-duplicated — see above) mapped to
// chip-rail display rows. Each row carries the raw source `option` when it is
// still present in `options` (mirroring how filteredOptions() attaches the raw
// option to every wrapper row), or a raw-value fallback label when the option
// has disappeared from an asynchronously swapped `options` array — the locked
// R1 concurrency edge: an orphan chip persists, labelled by its raw value,
// rather than vanishing. `value` array order IS chip display order (R1
// locked); selectedValues() already preserves it.
chipRows = () => {
const opts = Array.isArray(this.options) ? this.options : [];
return this.selectedValues().map((v: any) => {
const found = opts.find((o: any) => this.valueOf$local(o) === v);
return found ? {
value: v,
label: this.labelOf(found),
option: found
} : {
value: v,
label: String(v),
option: null
};
});
};
// chipRemoveLabel(row): the aria-label naming what a chip's remove control removes.
chipRemoveLabel = (row: any) => 'Remove ' + String(row.label);
// removeChipValue(v) is defined AFTER selectOption() below (not here) — React's
// emitter derives each `useCallback`'s static dependency array from the
// helpers its body calls, and `removeChipValue` calls `selectOption`. Declaring
// it before `selectOption`'s own `const` would put `selectOption` in
// `removeChipValue`'s deps array ahead of its OWN initializer in the SAME
// module scope — a real same-render TDZ (`ReferenceError` at runtime on
// React, TS2448 "used before its declaration" at typecheck). Source order
// here IS emission order for these plain top-level consts, so
// `removeChipValue` must textually follow `selectOption`.
// ---- 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`). `effectiveCloseOnSelect()` gates the popup close.
selectOption = (opt: any) => {
if (!opt) return;
if (opt.isMore) {
this.expandGroup(opt.group);
this._activeIndex.value = opt._i;
return;
}
if (opt.isCreate) {
// Read locals before any write (ROZ138 idiom).
const q = this._inputText.value;
const nq = this.normalizedQuery();
// The double-commit latch (D-17/D-20): a second commit of the SAME
// normalized query — whether a rapid double gesture, or the async
// round-trip window before the consumer's `options` update lands — is a
// no-op. An empty/whitespace normalized query never emits either (the
// row should not even be reachable then, since isCreatableQuery() gates
// it, but this guard is cheap insurance against a stale reference).
if (!nq || nq === this._createdQuery.value) return;
this._createdQuery.value = nq;
this.dispatchEvent(new CustomEvent<ComboboxCreatePayload>("create", {
detail: {
query: q
},
bubbles: true,
composed: true
}));
// D-20: after `create` fires, local UI state behaves like a pick — the
// effective close-on-select applies, and the query clears in `multiple`
// mode (ready for the next entry) and is left alone in single mode (the
// consumer's async add flows back through the ordinary `value` watch).
// `value` itself is untouched — R3 locked.
if (this.effectiveCloseOnSelect()) this._isOpen.value = false;
if (this.multiple) this.clearQuery(null);
this._activeIndex.value = -1;
return;
}
if (opt.disabled) return;
if (this.multiple) {
// Capture whether the value was already present BEFORE the toggle — this
// local is what feeds the `selected` field on the `change` payload (D-15).
const cur = this.selectedValues();
const wasSelected = cur.indexOf(opt.value) !== -1;
// Fresh array on every commit — in-place mutation (.push/.splice) is
// silently dropped by the React/Solid/Lit/Angular change detectors.
const next = wasSelected ? cur.filter((v: any) => v !== opt.value) : [...cur, opt.value];
this._valueControllable.write(next);
// D-14: clear the query on pick under `multiple` (not the option's label)
// so Backspace-removes-last stays reachable immediately after a pick.
// `opt.isRemoval` (set only by removeChipValue() below) skips this —
// removing a chip is not a pick, and clobbering whatever the user was
// mid-typing in the search box is a separate, unrelated data loss.
if (!opt.isRemoval) this.clearQuery(null);
if (this.effectiveCloseOnSelect()) this._isOpen.value = false;
this._activeIndex.value = -1;
this.dispatchEvent(new CustomEvent<ComboboxChangePayload>("change", {
detail: {
value: next,
option: opt.option,
selected: !wasSelected
},
bubbles: true,
composed: true
}));
return;
}
this._valueControllable.write(opt.value);
this._inputText.value = String(opt.label);
if (this.effectiveCloseOnSelect()) this._isOpen.value = false;
this._activeIndex.value = -1;
// D-15: `selected` is additive and always `true` in single-select.
this.dispatchEvent(new CustomEvent<ComboboxChangePayload>("change", {
detail: {
value: opt.value,
option: opt.option,
selected: true
},
bubbles: true,
composed: true
}));
};
// removeChipValue(v): routes chip removal through the EXACT SAME toggle path
// selectOption() uses for a re-select — a synthetic wrapper row is enough,
// since the `multiple` branch above only reads `opt.value`/`opt.option`/
// `opt.disabled`/`opt.isMore` — so removal and toggle-off can never diverge
// into different payload shapes. Declared here, after selectOption(), not
// alongside chipRows()/chipRemoveLabel() above — see the comment there.
removeChipValue = (v: any) => {
const opts = Array.isArray(this.options) ? this.options : [];
const found = opts.find((o: any) => this.valueOf$local(o) === v);
// isRemoval: true tells selectOption()'s `multiple` branch this is a
// removal, not a pick — see the D-14 comment there.
this.selectOption({
value: v,
option: found || null,
isRemoval: true
});
};
// onChipRemovePointerDown() (quick-260903-0s1, E1 audit finding): the POINTER
// half of the chip remove control's split binding. Deliberately empty —
// the `.prevent` modifier this is bound to (mousedown) is its ENTIRE payload:
// preventDefault on mousedown suppresses the native focus shift, which is
// what keeps the input focused, keeps onBlur() from firing, and therefore
// keeps the popup open (the CR-02 hazard commit `d02a145ef` closed). The
// removal deliberately does NOT live here: preventDefault on mousedown does
// NOT suppress the click that follows it, so a handler bound to BOTH events
// would remove the chip twice per pointer press. See onChipRemoveActivate()
// below for where the removal actually happens.
onChipRemovePointerDown = () => {};
// onNativeInputChange() (release-0.8.0): the `.stop` on the input's native
// `change` is its whole payload — the native event bubbles out of the inner
// <input> on blur after an edit, and on Angular (no shadow boundary) a consumer
// `(change)` binding on <rozie-combobox> would receive that DOM Event as well as
// the component's own `change` output (the same collision popover's audit B6
// removed). Stopping it keeps `change` meaning only the component event.
onNativeInputChange = () => {};
// onChipRemoveActivate(v) (quick-260903-0s1, E1 audit finding): the CLICK half
// of the split binding — the actual removal. `click` is the one event every
// activation path produces: a real pointer press (mousedown+click), Enter or
// Space on the focused button (native <button> behavior fires `click`, never
// `keydown`-observable-as-such), AND a screen reader's synthesized activation
// (which emits `click` with no preceding `mousedown` at all — the E1 defect
// this fixes). Binding removal to `click` alone covers all three with exactly
// one removal per activation.
//
// Keyboard/AT activation puts DOM focus ON the button, which this removal
// then unmounts — without an explicit refocus, focus would fall to
// `document.body`. Restore it using the EXACT idiom onFocus() above already
// uses (proven on all six targets): a queued microtask that refocuses
// `$refs.inputEl` only when it exists and is not already `document.activeElement`.
// That activeElement guard is what makes this a strict no-op on the pointer
// path — a pointer press never moves focus off the input in the first place
// (onChipRemovePointerDown's preventDefault sees to that), so this refocus
// never re-enters onFocus() and never re-selects the in-progress query.
// $refs is safe here for the same reason it is safe everywhere else in this
// file: this is a post-mount event handler, not module-init code.
//
// `.stop` on the template's `@click` binding (real-browser VR finding,
// quick-260903-0s1): on Solid and Svelte specifically — the two targets whose
// reactivity applies a DOM mutation SYNCHRONOUSLY, inside the very handler
// that triggered it, rather than batched to a microtask like the other four
// — removing this chip's own `<li>` mid-click detaches the click event's
// `target` from the document BEFORE the event finishes bubbling. Popover's
// own document-level `@click.outside($refs.anchorEl,$refs.floatingEl)`
// dismiss listener (Popover.rozie) then evaluates `anchorEl.contains(target)`
// against the NOW-DETACHED target, which is unconditionally `false` for any
// detached node — misreading this internal removal as an outside click and
// closing the popup. `.stop` (stopPropagation) keeps this click from ever
// reaching that document listener, exactly like the sibling `@mousedown.stop`
// pattern command-palette's own action-menu-affordance row already uses to
// keep an inner gesture from bubbling into an ancestor's own listener.
onChipRemoveActivate = (v: any) => {
this.removeChipValue(v);
queueMicrotask(() => {
if (this._refInputEl && document.activeElement !== this._refInputEl) this._refInputEl.focus();
});
};
// Reflect the externally-selected value into the input text. D-14: no-ops
// under `multiple` — there is no single label to mirror into the input once
// `value` holds an array, and the query is owned by chip-picking instead.
//
// quick-260903-0s1 (E2 audit finding): routed through the SAME valueOf()/
// labelOf() resolvers every other option read in this file uses
// (filteredOptions(), chipRows(), removeChipValue(), queryMatchesOption()) —
// this was the single site that still read the raw `.value`/`.label`
// properties directly. `optionValue`/`optionLabel` are documented public
// props, and the resolvers additionally carry the primitive-option fallback
// (`String(opt)` when `opt` has no `.label`) — bypassing them blanked the
// input on both the mount path ($onMount → syncQueryToValue()) and the
// external-value path ($watch(() => $props.value, ...) → syncQueryToValue()).
//
// The "not found" guard is on `opt` being neither `undefined` NOR `null`,
// deliberately not on truthiness: with primitive options the found entry IS
// the option, so a legitimate selection of an empty string or a zero would be
// discarded by a truthiness test and re-blank the input — reintroducing the
// bug in a new shape. `Array.prototype.find` returns `undefined` on a miss,
// so that is the correct miss test; the `null` check keeps a `null` option
// from rendering as the literal text "null".
syncQueryToValue = () => {
if (this.multiple) return;
const opts = Array.isArray(this.options) ? this.options : [];
const opt = opts.find((o: any) => this.valueOf$local(o) === this.value);
this._inputText.value = opt === undefined || opt === null ? '' : String(this.labelOf(opt));
};
// ---- free-text commits (COMBOBOX-SPEC items 5-7, multiple only) --------
// delimiterList(): the `delimiters` prop normalized to an array.
delimiterList = () => Array.isArray(this.delimiters) ? this.delimiters : [];
// splitDelimiters(): the CHARACTER delimiters (everything but 'Enter'/'Tab') —
// the paste split characters.
splitDelimiters = () => this.delimiterList().filter((k: any) => k !== 'Enter' && k !== 'Tab');
// freeTextOn(): free-text commits are enabled under `multiple` when a delimiter
// list, a validate function, a splitPaste function or commitOnBlur is supplied.
freeTextOn = () => !!this.multiple && (this.delimiterList().length > 0 || typeof this.validate === 'function' || typeof this.splitPaste === 'function' || !!this.commitOnBlur);
// storedText(t): the `validate` gate + normaliser (Tags' shape), for an already
// trimmed, non-empty `t`. Returns the string to store, or null when rejected:
// absent validate ⇒ t; a string return ⇒ that string ('' rejects); any other
// truthy return (`true`) ⇒ t; a falsy return ⇒ rejected.
storedText = (t: any) => {
if (typeof this.validate !== 'function') return t;
const r = this.validate(t);
if (!r) return null;
return typeof r === 'string' ? r : t;
};
// commitTexts(texts): append every not-yet-present text to `value` (ONE fresh
// array, ONE model write) and emit one `change` per committed text, each with the
// running array as of that commit. Texts already present are skipped silently.
commitTexts = (texts: any) => {
let next = this.selectedValues();
const committed = [];
const snapshots = [];
for (let i = 0; i < texts.length; i++) {
const t = texts[i];
if (next.indexOf(t) !== -1) continue;
next = next.concat([t]);
committed.push(t);
snapshots.push(next);
}
if (committed.length > 0) this._valueControllable.write(next);
this._activeIndex.value = -1;
for (let i = 0; i < committed.length; i++) {
this.dispatchEvent(new CustomEvent<ComboboxChangePayload>("change", {
detail: {
value: snapshots[i],
option: null,
selected: true,
text: committed[i]
},
bubbles: true,
composed: true
}));
}
};
// syncInputText(el, text): also write the LIVE input element. Angular compares a
// `[value]` binding against its last RENDERED value: fast typing followed by a
// commit in the same frame (before change detection rendered the typed text)
// leaves query '' === last-rendered '' — no DOM write, the typed text stays.
// Writing the element directly is idempotent on every other target.
syncInputText = (el: any, text: any) => {
if (el && typeof el.value === 'string' && el.value !== text) el.value = text;
};
// setTypedText(q, el): the input text changed to `q` — by typing (onInput) or by a
// paste Combobox handled itself (insertAtCaret). Re-arms the create latch, opens
// the list, highlights the first row and emits `search`, exactly as typing does.
setTypedText = (q: any, el: any) => {
this._inputText.value = q;
this.syncInputText(el, q);
// Any input change re-arms the double-commit latch (D-17/D-20) — a
// freshly-typed query is a new gesture, never a repeat of whatever was
// last created.
this._createdQuery.value = null;
this._isOpen.value = true;
this._activeIndex.value = 0;
this.dispatchEvent(new CustomEvent<ComboboxSearchPayload>("search", {
detail: {
query: q
},
bubbles: true,
composed: true
}));
};
// clearQuery(el): Combobox clearing the input text ITSELF (a pick under
// `multiple`, a create under `multiple`, a free-text commit, clear()). Emits
// `search` with '' so a host tracking the query through `search` never goes
// stale — a free-text commit of an already-selected value fires no `change`,
// so this is the host's only signal. No emit when the text was already empty.
// The live element is consulted too: on React a commit in the same frame as the
// last keystroke still sees the pre-keystroke `inputText` in its closure.
clearQuery = (el: any) => {
const had = this._inputText.value !== '' || !!(el && typeof el.value === 'string' && el.value !== '');
this._inputText.value = '';
this.syncInputText(el, '');
if (had) this.dispatchEvent(new CustomEvent<ComboboxSearchPayload>("search", {
detail: {
query: ''
},
bubbles: true,
composed: true
}));
};
// insertAtCaret(el, text): insert `text` into the input at the caret, replacing
// the selection — what an ordinary paste does — and leave the caret after it.
insertAtCaret = (el: any, text: any) => {
const cur = el && typeof el.value === 'string' ? el.value : String(this._inputText.value);
const start = el && typeof el.selectionStart === 'number' ? el.selectionStart : cur.length;
const end = el && typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
const next = cur.slice(0, start) + text + cur.slice(end);
this.setTypedText(next, el);
const caret = start + text.length;
if (el && typeof el.setSelectionRange === 'function') el.setSelectionRange(caret, caret);
};
// commitFreeText(raw, el): trim → validate (normalise) → commit + clear the input.
// Returns true when the text was handled (committed, or already present ⇒ just
// cleared); false when empty or rejected — rejected text stays in the input.
commitFreeText = (raw: any, el: any) => {
const t = String(raw == null ? '' : raw).trim();
if (!t) return false;
const stored = this.storedText(t);
if (stored === null) return false;
this.clearQuery(el);
this.commitTexts([stored]);
return true;
};
// splitOnDelimiters(text): the built-in paste split — the clipboard text split on
// every CHARACTER delimiter, or null when it contains none (an ordinary paste).
splitOnDelimiters = (text: any) => {
const seps = this.splitDelimiters();
let hasSep = false;
for (let s = 0; s < seps.length; s++) {
if (text.indexOf(seps[s]) !== -1) hasSep = true;
}
if (!hasSep) return null;
let parts = [text];
for (let s = 0; s < seps.length; s++) {
const out = [];
for (let p = 0; p < parts.length; p++) {
const pieces = String(parts[p]).split(seps[s]);
for (let q = 0; q < pieces.length; q++) out.push(pieces[q]);
}
parts = out;
}
return parts;
};
// onPaste(e) (item 6): under free-text mode the clipboard text is split — by
// `splitPaste` when supplied, else on the character delimiters — and every
// non-empty trimmed part `validate` accepts is committed (the paste is
// preventDefault-ed). The rejected parts (joined by the first delimiter) are
// inserted at the caret, replacing the selection, as an ordinary paste would be,
// so text typed before the paste is kept. A split of null (splitPaste said "not
// mine", or no delimiter in the text) leaves the paste to the browser.
onPaste = (e: any) => {
if (!this.freeTextOn()) return;
const text = e && e.clipboardData && e.clipboardData.getData('text') || '';
// typeof checked inline (not via a local flag) so strict TS narrows the call.
const split = typeof this.splitPaste === 'function' ? this.splitPaste(text) : this.splitOnDelimiters(text);
if (!Array.isArray(split)) return;
if (e) e.preventDefault();
const accepted = [];
const rejected = [];
for (let i = 0; i < split.length; i++) {
const part = String(split[i] == null ? '' : split[i]).trim();
if (!part) continue;
const stored = this.storedText(part);
if (stored === null) rejected.push(part);else accepted.push(stored);
}
const seps = this.splitDelimiters();
const rest = rejected.join(seps.length > 0 ? seps[0] + ' ' : ' ');
if (rest) this.insertAtCaret(e ? e.target : null, rest);
this.commitTexts(accepted);
};
// ---- input + keyboard handlers -----------------------------------------
onInput = (e: any) => {
const q = e && e.target ? e.target.value : '';
this.setTypedText(q, null);
};
onFocus = (e: any) => {
// Phase 86 R2 (plan 86-03), Solid-only reentrancy guard: the input now
// renders inside the composed popover's SCOPED `#anchor` slot
// (`:open="$props.open"` among its params — see the <Popover> template
// comment for why the input moved there). On Solid, a named slot invocation
// with reactive scope params is a plain closure CALL re-run whenever any
// param changes (@rozie/core's documented, intentional Solid
// slot-reactivity design — not a bug to route around at the emitter level):
// the `isOpen` write below changes the `open` param this exact handler is
// responding to, which on Solid SYNCHRONOUSLY recreates the anchor's DOM
// subtree (Solid's JSX has no virtual-DOM diffing to preserve node identity
// across a closure re-invocation) — removing the just-focused `<input>`
// fires a NATIVE blur on it, mid-call-stack, before this function even
// returns. Without the guard below, that blur's own onBlur() would
// immediately set isOpen back to false, and the deferred re-focus further
// down would restart the SAME cycle on the fresh node — an infinite
// recreate/blur/close/refocus loop. `openingInProgress` (below) tells
// onBlur "this blur is a side effect of OUR OWN isOpen write, not the user
// moving focus away" so it can skip closing. The other 5 targets diff their
// scoped-slot re-render and keep the existing, already-focused node — no
// blur ever fires there, so the guard is a no-op for them.
// disableOpenOnFocus (item 3): focus alone never opens the list — typing
// (onInput) and ArrowDown/ArrowUp (onKeydown) still do.
if (this.disableOpenOnFocus) {
if (e && e.target && e.target.select) e.target.select();
return;
}
this.openingInProgress = true;
this._isOpen.value = true;
// Cleared SYNCHRONOUSLY, immediately after the write — Solid's reactive
// cascade (if any) runs SYNCHRONOUSLY as part of that write, before this
// line executes, so the guard window covers exactly the recreate/blur
// cascade and nothing past it. A deferred (microtask) clear would leave a
// stale `true` window spanning an `await` boundary whenever the re-focus
// below re-enters onFocus, incorrectly suppressing a LATER, genuine blur.
this.openingInProgress = false;
if (e && e.target && e.target.select) e.target.select();
queueMicrotask(() => {
// Re-assert focus onto whatever node is CURRENT — after Solid's
// synchronous signal-write reactivity (if any) has already run and
// `$refs.inputEl` reflects the latest node — recovering focus if it was
// stranded on a since-removed one.
if (this._refInputEl && document.activeElement !== this._refInputEl) this._refInputEl.focus();
});
};
// @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.
// While `pinned` (pinOpen(true)), early-return BEFORE the isOpen write — a host
// sub-surface (e.g. command-palette's action flyout) is holding focus and the
// popup must stay open until the host calls pinOpen(false) itself. While
// `openingInProgress` (Solid-only, see onFocus above), early-return too — this
// blur is a side effect of our OWN open-transition recreating the anchor's DOM,
// not the user moving focus elsewhere.
// commitOnBlur: leaving the field commits the typed text through validate (a blur
// into a pinned host sub-surface, or the Solid recreate blur, returned above).
onBlur = (e: any) => {
if (this._pinned.value) return;
if (this.openingInProgress) return;
this._isOpen.value = false;
if (this.commitOnBlur && this.freeTextOn()) {
const el = e ? e.target : null;
this.commitFreeText(el ? el.value : this._inputText.value, el);
}
};
onKeydown = (e: any) => {
// B10: ignore every key while an IME composition is active — the Enter that
// confirms a composition must never pick, commit or navigate. Read through
// `nativeEvent` when present: React's synthetic keyboard event does not carry
// `isComposing` (every other target hands the native event straight through).
const ne = e && e.nativeEvent ? e.nativeEvent : e;
if (ne && (ne.isComposing || ne.keyCode === 229)) return;
const key = e ? e.key : '';
const list = this.navRows();
// 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;
const visible = this.popupVisible();
const liveText = e && e.target ? e.target.value : '';
const highlighted = wasOpen && ai >= 0 && list[ai] ? list[ai] : null;
// Character delimiters (item 5): commit the TYPED text — never the highlighted
// option. 'Enter' / 'Tab' entries are handled in their own branches below.
if (this.freeTextOn() && key !== 'Enter' && key !== 'Tab' && this.delimiterList().indexOf(key) !== -1) {
if (e) e.preventDefault();
this.commitFreeText(liveText, e ? e.target : null);
return;
}
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') {
// B9: Enter with Ctrl / Meta / Alt is left to the host (e.g. a send shortcut).
const modified = !!(e && (e.ctrlKey || e.metaKey || e.altKey));
if (!modified) {
if (highlighted) {
if (e) e.preventDefault();
this.selectOption(highlighted);
} else if (this.freeTextOn() && String(liveText).trim()) {
// Free-text mode (item 7): Enter with no highlighted option commits the
// typed text (rejected text stays in the input).
if (e) e.preventDefault();
this.commitFreeText(liveText, e ? e.target : null);
}
}
} else if (key === 'Tab') {
// selectOnTab (item 8): pick the highlighted option while the popup is
// visible; preventDefault ONLY when it picked. A 'Tab' delimiter commits the
// typed text when nothing was picked. Otherwise Tab moves focus normally.
if (this.selectOnTab && visible && highlighted && !highlighted.disabled) {
if (e) e.preventDefault();
this.selectOption(highlighted);
} else if (this.freeTextOn() && this.delimiterList().indexOf('Tab') !== -1 && String(liveText).trim()) {
if (this.commitFreeText(liveText, e ? e.target : null) && e) e.preventDefault();
}
} else if (key === 'Escape') {
// B4: only consume Escape when the popup is actually VISIBLE.
if (visible) {
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);
}
} else if (key === 'Backspace') {
// Backspace-removes-last-chip (Tags.rozie precedent, Phase 86 R1 plan
// 86-05): guarded on `multiple` AND the LIVE input value being empty —
// read `e.target.value` directly (Tags' proven idiom), never the mirrored
// `$data.inputText`. A non-empty query falls through to normal text editing —
// nothing here removes a chip while there is text to delete.
if (this.multiple) {
const liveValue = e && e.target ? e.target.value : '';
if (liveValue === '') {
const cur = this.selectedValues();
if (cur.length > 0) {
if (e) e.preventDefault();
this.removeChipValue(cur[cur.length - 1]);
}
}
}
}
// Keep the (new) active option in view — routes through the virtualizer when
// windowing, direct scrollIntoView otherwise.
this.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.
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);
}
};
// buildVirtualizer() (combobox-virtual-reactivity, VIRT-BUILD): the SINGLE virtualizer
// construction site — called from $onMount below (mount-time virtual:true) AND from the
// virtual $watch further down (a runtime false→true flip), so the mount path can never
// drift from the flip path. Guarded so a build queued (rAF-deferred by the $watch) that
// fires AFTER a flip-back is a no-op (rapid-flip idempotence), and so calling it twice
// never double-constructs.
buildVirtualizer = () => {
if (!this.virtual || this.virtualizer) return;
// 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. 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.
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);
};
// teardownVirtualizer() (VIRT-TEARDOWN): runs the SAME per-instance cleanup fn
// $onUnmount invokes below, then nulls the instance state + bumps windowVer so the
// windowed template branch (still mounted while $props.virtual — CR-01) re-derives to
// the pre-construction fallback state instead of holding a stale virtualizer. This is
// the true→false ResizeObserver-leak fix: previously ONLY $onUnmount ever called
// virtualizerCleanup, so a runtime flip to non-virtual left the observer live.
teardownVirtualizer = () => {
if (this.virtualizerCleanup) this.virtualizerCleanup();
this.virtualizer = null;
this.virtualizerCleanup = null;
this.gridScrollEl = null;
this._windowVer.value = this._windowVer.value + 1;
};
// nextAutoId(): a page-wide counter shared by every Rozie component instance. It
// lives on globalThis (read through Reflect, which type-checks in the plain-JS and
// the TS script alike) so separately bundled copies of a leaf never hand out the
// same id. The same four lines live in Combobox, Listbox and Popover.
nextAutoId = () => {
const n = (Number(Reflect.get(globalThis, '__rozieAutoId')) || 0) + 1;
Reflect.set(globalThis, '__rozieAutoId', n);
return n;
};
// focus() — focus the input (accepted ROZ137 Lit override). clear() — reset the
// selection + query. seedQuery(text) — imperative-only: write the input text
// (and therefore filteredOptions()'s filter) without touching the `value`
// model or selection state (a command-palette #2 levels/restore-on-pop
// prerequisite — repopulating the input on back-navigation is NOT a
// selection). pinOpen(v) — imperative-only: pin (or unpin) the popup open so
// onBlur() does not collapse it while a host sub-surface holds focus, AND
// (Phase 86-07 regression fix) so the composed Popover's OWN independent
// Escape/click-outside dismissal is vetoed too via `:disable-dismiss`
// (command-palette-sub-actions prerequisite). pinOpen(false) ONLY unpins — it
// does NOT itself close the popup or move focus; that is the host's job.
// Render-neutral when never called. All four are post-mount → $refs safe.
focus: () => void = () => this._refInputEl?.focus();
clear: () => void = () => {
// Fresh empty array under `multiple` (never in-place mutation), null in
// single mode — mirrors selectOption()'s `{ value, option, selected }`
// shape; nothing is selected after a clear, so `selected` is `false`.
const empty = this.multiple ? [] : null;
this._valueControllable.write(empty);
this.clearQuery(null);
this._activeIndex.value = -1;
this.dispatchEvent(new CustomEvent<ComboboxChangePayload>("change", {
detail: {
value: empty,
option: null,
selected: false
},
bubbles: true,
composed: true
}));
};
seedQuery: (text: string) => void = (text: any) => {
this._inputText.value = String(text == null ? '' : text);
};
pinOpen: (v: boolean) => void = (v: any) => {
this._pinned.value = !!v;
};
// query() — the current input text (what the last `search` reported).
query: () => string = () => this._inputText.value;
get value(): unknown { return this._valueControllable.read(); }
set value(v: unknown) { this._valueControllable.notifyPropertyWrite(v); }
addEventListener<K extends keyof RozieComboboxEventMap>(type: K, listener: (this: Combobox, ev: RozieComboboxEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void {
super.addEventListener(type, listener, options);
}
removeEventListener<K extends keyof RozieComboboxEventMap>(type: K, listener: (this: Combobox, ev: RozieComboboxEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void {
super.removeEventListener(type, listener, options);
}
/**
* 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).
*
* command-palette-per-level-virtual / portal-through-portal cluster —
* `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
* attribute, never a consumer prop) so a parent-assigned `ref=` on this
* component's own host tag can never clobber this component's OWN
* internal `data-rozie-ref` ref markers via fallthrough re-application.
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['data-rozie-ref', 'value', 'options', 'placeholder', 'disabled', 'disable-filter', 'disablefilter', 'aria-label', 'arialabel', 'id-base', 'idbase', 'inline', 'close-on-select', 'closeonselect', 'multiple', 'creatable', 'option-label', 'optionlabel', 'option-value', 'optionvalue', 'option-disabled', 'optiondisabled', 'virtual', 'estimate-row-height', 'estimaterowheight', 'max-height', 'maxheight', 'groups', 'group-cap', 'groupcap', 'placement', 'offset', 'disable-flip', 'disableflip', 'disable-shift', 'disableshift', 'block', 'chip-layout', 'chiplayout', 'disable-open-on-focus', 'disableopenonfocus', 'hide-empty', 'hideempty', 'delimiters', 'validate', 'split-paste', 'splitpaste', 'commit-on-blur', 'commitonblur', 'select-on-tab', 'selectontab']);
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 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 — identical on every target, 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.