<!-- GENERATED by scripts/build-llms.mjs from llms/layout.md — do not edit this file. -->

# `lr-virtual-list`

- **Import** `import '@aceshooting/lyra-ui/components/lr-virtual-list.js';` (stable tag alias; registers the tag)
- **Class** `LyraVirtualList`, also available unregistered from `@aceshooting/lyra-ui/components/layout/virtual-list/virtual-list.class.js`
- **Family** `components/layout/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 5 parts, 7 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-virtual-list`

A generic windowed/virtualized list host. Renders only the items within the current viewport (plus
`overscan` padding rows on each side) as real DOM, regardless of how large its source is, so a
multi-thousand-row chat-history sidebar (or a long message thread) stays cheap to scroll. Content is
entirely caller-supplied: `renderItem(item, index)` returns whatever `lit-html` value should represent
that row, and `keyFunction(item, index)` gives it a stable identity for DOM reconciliation. First-party
invention (no `wa-*`/`sl-*` counterpart).

Before a viewport can be measured, including during server rendering, the component emits a bounded
deterministic first window (the first visible row plus `overscan`) rather than a false empty list.
Hydration preserves that window on its first pass and then reconciles it with the measured browser
viewport; an ordinary browser-only mount retains the established empty-until-measured range-event
contract.

**Properties:**

- `items: readonly unknown[] = []` (attribute: false) — the full, non-windowed item collection. JS-only; set via
  a property/lit-html binding (`.items=`), not an HTML attribute. This remains the compatibility
  source whenever `source` is unset. Its sequence is copied, bounded, and frozen while generic row
  identities are retained; reassign a new array after sequence changes.
- `source?: LyraVirtualListSource` (attribute: false) — a readonly array or a count/index-backed
  `{ readonly count: number; itemAt(index): unknown; keyAt?(index): string | number;
indexOfKey?(key): number }`. When set it takes precedence over `items`. The indexed form performs
  bounded random access for only the rendered window instead of allocating `0…count`; invalid counts
  normalize to zero. Prefer a stable object identity and stable `keyAt`/`indexOfKey` implementations
  for synthetic, paged, or remote collections. `indexOfKey` is required when `active-item-id` should
  target an indexed source: the list never performs a count-sized fallback scan; invalid or
  out-of-range results mean no match. An array source receives the same clone-owned frozen sequence
  and row-identity contract as `items`; an indexed-source object passes through by identity.
- `renderItem: (item: unknown, index: number) => unknown = () => nothing` (attribute: false) — renders
  one row's content, typically returning a `lit-html` `TemplateResult`. JS-only. The returned value
  is stamped inside `<lr-virtual-list>`'s own shadow root, not the caller's light DOM, so
  document-level selectors cannot style arbitrary returned descendants. Use inherited custom
  properties, the public row parts, or a self-styled custom element in the returned template.

**Narrow rows:** ordinary `renderItem` content can shrink and wraps even at a 320px allocation,
including an otherwise-unbroken value; in `row-height="auto"` mode the measured row height follows
those extra lines. This is direction-neutral: LTR and RTL use the same inline-size containment. To
intentionally preserve an unbroken row, set `white-space: nowrap` on the caller-rendered content; the
list's `base` scroll container exposes horizontal scrolling for that explicit opt-out.

- `keyFunction?: (item: unknown, index: number) => string | number` (attribute: false) — derives a
  row's stable reconciliation key. JS-only. Falls back to the effective source index when omitted,
  which is only a safe identity while a collection never reorders/inserts/removes — provide this
  whenever possible, or scroll position and per-row DOM state (e.g. an `<audio>` element's playback
  position) can attach to the wrong row across a mutation.
- `groups?: LyraVirtualListGroup[]` (attribute: false) — renders a labeled marker at each group's
  `startIndex` as a measured virtual entry immediately before that row. Its live block size
  contributes to every following offset, so a variable-height or late-resizing marker never covers
  the group's first row. Markers remain windowed with their rows. Groups are sorted by `startIndex`;
  a non-object entry or a `startIndex` that's non-integer, out of range, or a duplicate of an earlier
  group's is silently dropped rather than rendered wrong. An entry whose
  `label` is the **empty string** renders no marker at all — it is a pure position anchor, for a host
  that renders its own group header as an ordinary row (and would otherwise end up with two stacked
  headers) but still needs this component to know where each group starts, e.g. to drive
  `renderStickyGroup` below. Omitting `label` entirely still falls back to rendering `key`.
- `renderStickyGroup?: (group: LyraVirtualListGroup) => unknown` (attribute: false) — renders a pinned
  copy of whichever `groups` entry the viewport is currently inside, into a `[part="sticky-group"]`
  overlay that stays at the top of the scroll viewport and is pushed out by the overlap as the next
  group's header arrives (rather than swapped abruptly at the boundary). Native `position: sticky` on
  the rows or markers themselves cannot do this: every row is absolutely positioned and
  transform-offset by the windowing math, which makes sticky structurally inert. Unset (the default)
  renders no overlay element whatsoever and changes nothing about the list's output. The overlay is a
  _visual copy_ of content that already exists in the list, which fixes its contract:

  - it is `aria-hidden` and `inert`, so the real row keeps sole ownership of heading semantics,
    focus and activation. The component never traverses or rewrites callback-owned descendants,
    including open custom-element shadow roots;
  - it is pointer-transparent and deliberately cannot become a mouse-only action. Put interactive
    group actions in the real row;
  - it is never measured as a row, so a group header that is also a real row is not double-counted in
    `row-height="auto"` mode;
  - its measured height is applied as `scroll-padding-block-start` on the scroll container and
    subtracted from top-aligned scroll targets, so `active-item-id`, `scrollToIndex({ align: 'start' })`
    and native keyboard scrolling all stop _below_ the band instead of parking the row behind it.

  The callback runs on every scroll-driven update, so keep it cheap and side-effect free. While the
  viewport is above the first group there is nothing to pin: the band shows nothing, but it stays
  mounted (called with the first group, rendered hidden) so its height is known before the first
  programmatic jump rather than only after it.

- `rowHeight: number | 'auto' = 'auto'` (attribute `row-height`) — `'auto'` measures each row's real
  height via `ResizeObserver`; a numeric markup value (for example `row-height="56"`) parses to the
  number `56` and fixes every row to that many pixels. Property callers assign a number, not a
  numeric string. Anything else (non-numeric, zero, negative, non-finite) safely canonicalizes to
  `'auto'` rather than throwing.
- `rowProjection: 'shadow' | 'light' = 'shadow'` (attribute `row-projection`) — where
  `renderItem`'s output is instantiated. `'shadow'` (default) stamps it inside this component's own
  shadow root, so only inherited custom properties and the public row parts reach it. `'light'`
  renders the windowed rows into the host's own light DOM instead, assigned into the shadow viewport
  through internal named slots, so ordinary document CSS styles a virtualized row exactly as it
  styles the same row unvirtualized. The component keeps owning windowing, measurement, spacer
  sizing, `scrollToIndex()`, the external-scroller mode and the ARIA contract either way, and
  positioning stays on the shadow-side `[part="row"]` wrapper that document CSS cannot select — so
  consumer styles can never break windowing. Any other value canonicalizes to `'shadow'`. Left
  unset, the rendered output is byte-identical to before and the host's light DOM stays empty.
  See **Light-DOM row projection** below for the trade-offs it carries.
- `itemRole: 'listitem' | 'row' = 'listitem'` (attribute `item-role`) — `'listitem'` (default)
  preserves the plain `role="list"`/`role="listitem"` mapping with `aria-setsize`/`aria-posinset`.
  `'row'` additionally maps `[part="base"]` to `role="rowgroup"`, `[part="spacer"]` to
  `role="presentation"`, and each row to `role="row"` with `aria-rowindex` instead — for a consumer
  composing its own `role="table"` wrapper and header row around this component (see
  `lr-dataset-viewer`).
- `rowIndexOffset: number = 0` (attribute `row-index-offset`) — added to a row's 1-based index to
  compute `aria-rowindex` in `item-role="row"` mode (e.g. `1` when a consumer renders its own header
  row occupying `aria-rowindex="1"` outside this component). Negative and non-finite values become
  zero, fractions are truncated, and the final positive ARIA integer saturates safely. No effect in
  `'listitem'` mode.
- `overscan: number = 6` — extra rows rendered beyond the visible viewport on each side; finite
  values are floored and clamped to 0–100, while non-finite values use the default 6, so an invalid
  runtime value cannot disable windowing and render the entire collection.
- `activeItemId: string | number | '' = ''` (attribute `active-item-id`) — when set and it matches a row's `keyFunction`
  result (compared with `Object.is` against the typed value — attribute values arrive as strings, so
  assign the property directly for a numeric key), that row is smoothly scrolled into view whenever
  this changes, and rendered with `aria-current="true"`.
- `loading: boolean = false` (reflected) — sets `aria-busy` on the scroll container and a `cursor:
progress` style, and gates `lr-load-more` while a consumer's fetch is in flight.
- `hasMore: boolean = false` (attribute `has-more`, reflected) — when true, scrolling near the bottom
  fires `lr-load-more` (gated by `loading`).
- `scrollElement?: Element | Window` (attribute: false) — an ancestor that already owns a scrollbar,
  or the window itself, for a list embedded in a longer scrolling page rather than sized as its own
  panel. JS-only; set via a property/lit-html binding. While set, `[part="base"]` stops scrolling and
  grows to the list's full virtual extent, so the page's single scrollbar spans the whole list and
  `[part="sticky-group"]` sticks to that outer scrollport instead of this component's. Everything
  expressed in list coordinates keeps answering in list coordinates — `offsetForIndex()`,
  `indexAtOffset()`, `scrollToIndex()`, `active-item-id` scroll-into-view, and `lr-virtual-scroll`'s
  `scrollTop` are all still measured from the top of the list, with the component converting to and
  from the external scroller's position; auto-height scroll anchoring moves the external scroller
  too, so measuring a row above the viewport does not make the page jump. There is deliberately no
  ancestor auto-detection: the scroller is the element you name and nothing else, so adding an
  unrelated `overflow` rule to some wrapper can never silently take the job over. Two consequences:
  `[part="base"]` drops its `tabindex` and its hover outline, because it is no longer a scrollable
  region and a focus stop that scrolls nothing is worse than none — keyboard scrolling belongs to the
  external scroller; and horizontal scrolling of row content that opted out of wrapping becomes the
  external scroller's responsibility, since CSS cannot leave one axis visible while the other
  scrolls. The list's position inside the scroller is re-read on scroll, on the scroller's own
  resize, and whenever the list re-renders; a layout change _above_ the list that shifts it without
  any of those happening is not observable, so re-assign the property to force a re-read. Listeners
  follow the property — re-pointing it, disconnecting, and reconnecting all rebind against the
  current target and leave nothing behind on the previous one. A value that is neither an `Element`
  nor a `Window` is ignored and the list keeps scrolling its own viewport, so wiring this from a ref
  that is still empty on a first render is safe.

**Exported types:** `LyraVirtualListRowHeight = number | 'auto'`;
`LyraVirtualListSource<T> = readonly T[] | LyraVirtualListIndexedSource<T>` and
`LyraVirtualListIndexedSource<T> { readonly count: number; itemAt(index): T; keyAt?(index): string |
number; indexOfKey?(key: string | number): number }`; `LyraVirtualListRange { start: number; end: number }` (the `lr-visible-range-change`
detail shape); `LyraVirtualListGroup { key: string | number; label?: string; startIndex: number }` — the
shape consumed by `groups` above; `LyraVirtualListScroll { scrollTop: number; viewportHeight: number }` —
the `lr-virtual-scroll` detail shape.
`groupByRecency(items, options?)` is a DOM-free helper that returns non-empty
Today/Yesterday/Previous 7 Days/Older buckets, preserves input order within each bucket, and accepts
a timestamp extractor, reference date, and label overrides. Import it from its granular subpath —
the package root also re-exports it without registering custom elements, while the granular
route limits the named-export module graph. `@aceshooting/lyra-ui/all.js` is the separate eager
registration entry:

```ts
import { groupByRecency } from "@aceshooting/lyra-ui/utilities/group-by-recency.js";
```

**Methods:** `scrollToIndex(index, options?)` — the programmatic counterpart to `active-item-id`'s
automatic scroll-into-view, for a host that needs to scroll to a specific row without changing which
row is "active." `options.align` is `'start'`, `'end'`, or `'auto'` (default — no scroll at all when
already fully visible); `options.behavior` (default `'smooth'`) is forced to `'auto'` under
`prefers-reduced-motion: reduce`. `index` is clamped to the effective source's `0…count-1` range.
In auto-height mode, estimate-based jumps are corrected as row and group-marker measurements arrive.
That correction is bound to the source, key function, and target identity and is canceled on a new
target, data replacement, manual scroll intent, or disconnect, so late observations cannot pull a
newer view back to stale content.
`offsetForIndex(index)` returns the pixel top row `index` renders at, in the same coordinate space as
the scroll container's `scrollTop`; under an external `scrollElement` that space is unchanged — it
measures from the top of the list itself, not from the top of the external scroller's content, and
the component converts between the two. It is clamped to `0…count`, so `offsetForIndex(count)`
is the total content height and an empty list is always `0`. `indexAtOffset(px)` is its inverse — the
row whose box contains that offset, clamped at both ends, `-1` for an empty list — so
`indexAtOffset(offsetForIndex(i)) === i` and `indexAtOffset(scrollContainer.scrollTop)` is the row at
the top of the viewport. In `row-height="auto"` mode both are estimate-based for any row that (or
above which) has not been measured yet, and converge as those `ResizeObserver` measurements land;
fixed numeric `row-height` offsets are exact from the first render. Both read the most recent render,
so `await el.updateComplete` after assigning `items` or `source` before querying.

**Getters:** `scrollContainer: HTMLElement | undefined` — the real scroll container (`[part="base"]`),
`undefined` before the first render; for a host that needs the live scroll position or wants to scroll
the list itself without reaching into the shadow root. While `scrollElement` is set this element
still exists and still hosts every row, but it no longer scrolls — read and write the position on the
external scroller, or keep using `scrollToIndex()`, which targets whichever of the two is currently
in charge. `renderedRows: HTMLElement[]` — the row
wrappers (`[part="row"]`) that currently exist as real DOM, in item order (the current window, not the
whole collection; empty before the first render). It exists for hosts that must _reach_ a rendered row
rather than style it — keyboard focus management across a windowed list, where the row to focus may
not have existed a frame earlier, and which `exportparts` cannot serve since it forwards styling, not
element references. Treat both as read-only: positioning, keys, and lifetime belong to the windowing
math, and any row element can be recycled or removed on the next update.

**Events:** `lr-load-more` (no detail — fired once per approach to the bottom of the list while
`has-more` is true and `loading` is false; does not refire on every scroll tick while still near the
bottom — scrolling back away from the bottom and returning, or `items` growing enough to move the
window away from the end, re-arms it), `lr-visible-range-change` (`detail: LyraVirtualListRange`, the
current visible, non-overscanned item index range — fired only when it actually changes; it was
spelled `lr-visible-range-changed` before 10.0.0, the only past-tense `-changed` spelling among 58
`-change`-family events, so a convention-driven `lr-${x}-change` listener silently missed it),
`lr-virtual-scroll`
(`detail: LyraVirtualListScroll` — the scroll container moved. `scrollTop` is always in the list's own
offset space (`offsetForIndex()`'s space), including under an external `scrollElement`, where it is how
far the list has scrolled past the top of that scroller rather than the scroller's own position;
emitted from the same animation frame that
already coalesces native `scroll` events, so a fling produces at most one per frame and none at all
when the position did not change. Unlike `lr-visible-range-change`, which only fires on index-range
changes, this reports _sub-row_ movement, which is what scroll-linked layout needs)

**Slots:** none — all content comes from `renderItem`.

**CSS parts:** `base` (the scrollable container, `role="list"` — or `role="rowgroup"` in
`item-role="row"` mode — `tabindex="0"`; under an external `scrollElement` it stops scrolling, drops
that `tabindex` and its hover outline, and sizes itself to the list's full virtual extent instead of
`--lr-virtual-list-height`), `spacer` (the full-content-height inner element
establishing true scroll extent; `role="presentation"` in `item-role="row"` mode), `row` (one
rendered row's absolutely-positioned wrapper, `role="listitem"` — or `role="row"` with
`aria-rowindex` in `item-role="row"` mode), `group` (a `groups` entry's positioned marker; not
rendered for an entry whose `label` is the empty string), `sticky-group` (the pinned copy of the
current group, present only while `renderStickyGroup` is set — `aria-hidden`, `inert`, and
pointer-transparent, and it shows nothing while the viewport is above the first group)

**Themeable custom properties:** `--lr-virtual-list-height` (default `24rem` — the host's bounded
scroll extent; component-specific since a virtualized list is meaningless without a sized viewport,
and ignored while `scrollElement` names an external scroller, whose own height is the visible band),
plus shared `--lr-focus-ring-width/-color/-offset` (inward-offset ring on `[part="base"]`, negative
so it isn't clipped by the container's own `overflow: auto`). `[part="base"]` also carries a
mouse-hover outline — a subtler preview of that same `:focus-visible` ring, shown because the part
carries `tabindex="0"` and is a real keyboard-navigable target whenever it owns the scrollport (both
the tab stop and this outline are dropped under an external `scrollElement`) — tinted via
`--lr-virtual-list-hover-outline-color` (default `var(--lr-color-border-strong)`); set it to
`transparent` to opt out of the hover treatment entirely. Its remaining longhands are independently
themeable with `--lr-virtual-list-hover-outline-width` (default
`var(--lr-border-width-thin)`), `--lr-virtual-list-hover-outline-style` (default `solid`), and
`--lr-virtual-list-hover-outline-offset` (default
`calc(-1 * var(--lr-border-width-thin))`). All four hover-outline hooks are inline fallbacks and
there is intentionally no pressed state: the list viewport is a scroll surface rather than an
activation target. `[part="base"]` also honors the opt-in theme-level
`--lr-theme-scrollbar-width`/`--lr-theme-scrollbar-gutter` hooks (defaults `auto`/`auto`, matching
its previous unconditional `scrollbar-width: auto`) — set either on `:root` or any ancestor for one
declaration to retheme every internal scroll container in the library, including `lr-table`,
`lr-scroller`, `lr-carousel`, `lr-code-block`, and `lr-code-editor`.

**Optional peer deps:** none.

```ts
import { html } from "lit";

const view = html`<lr-virtual-list
  .items=${sessions}
  .renderItem=${(item, index) => html`
    <lr-conversation-item
      id=${item.id}
      title=${item.title}
      .timestamp=${item.updatedAt}
      ?active=${item.id === currentId}
    ></lr-conversation-item>
  `}
  .keyFunction=${(item) => item.id}
  active-item-id=${currentId}
  ?has-more=${hasMorePages}
  ?loading=${isLoadingMore}
  @lr-load-more=${() => loadNextPage()}
  @lr-visible-range-change=${(e) => console.log("visible", e.detail.start, e.detail.end)}
  @lr-virtual-scroll=${(e) => console.log("scroll top", e.detail.scrollTop)}
></lr-virtual-list>`;
```

```ts
import { html } from "lit";

// No count-sized array: only the current window is read.
const syntheticRows = {
  count: 100_000,
  itemAt: (index: number) => ({ page: index + 1 }),
  keyAt: (index: number) => index + 1,
  indexOfKey: (key: string | number) =>
    typeof key === "number" ? key - 1 : -1,
};

html`<lr-virtual-list
  row-height="72"
  .source=${syntheticRows}
  .renderItem=${(row) => html`Page ${row.page}`}
></lr-virtual-list>`;
```

```ts
import { html } from "lit";

// Sticky group headers: the header is a real row, so the `groups` entries are position anchors
// only (`label: ''`); the pinned copy remains strictly presentational.
const view = html`<lr-virtual-list
  .items=${rows}
  .groups=${groupStarts /* [{ key: 'Today', label: '', startIndex: 0 }, …] */}
  .renderItem=${(item, index) => (item.isHeader ? headerTemplate(item) : rowTemplate(item))}
  .renderStickyGroup=${(group) => headerTemplate(group)}
></lr-virtual-list>`;
```

Every row is positioned by a `transform: translateY(offset)`, rather than page flow, so only a small
DOM window exists while the scrollbar still reflects the full collection. Array sources retain their
cumulative-offset cache, rebuilt only when the collection/height/key inputs or a measurement change —
never on a pure scroll tick. Indexed sources never synthesize count-sized item, key, identity, or
offset arrays: fixed-height offsets are direct count arithmetic, while auto-height offsets combine the
default estimate with sparse `ResizeObserver` measurements for rows that have actually mounted.

**Known gotchas:**

- `items`, `source`, `renderItem`, `keyFunction`, and `groups` are all `attribute: false` — they must
  be set as JS properties (`.source=`, `.items=`, `.renderItem=`, …), never as HTML attribute strings.
- The container is `role="list"` with rows `role="listitem"`, deliberately not `listbox`/`option` —
  this component only provides windowing, not the roving-tabindex/`aria-activedescendant`
  keyboard-interaction contract a real `listbox` requires. `active-item-id` only scrolls a row into view and
  marks it `aria-current`; it is not a selection widget. Compose your own selection behavior on top if
  needed.
- `[part="base"]` carries `tabindex="0"` unconditionally, since `renderItem`'s caller-supplied content
  isn't guaranteed to contain a focusable element and an otherwise-unreachable-by-keyboard scroll
  region would result.
- Ordinary row content wraps by default, including long unbroken values. Set `white-space: nowrap`
  only for content that intentionally needs an unbroken horizontal scrollport; it overrides that
  default without clipping the row.
- `aria-setsize`/`aria-posinset` are computed from a row's real index in the full `items` array, not its
  position among the currently-rendered DOM window, so assistive tech still announces e.g. "item 12 of
  340" correctly even though only a handful of rows exist in the DOM at a time.
- `groups`, `renderStickyGroup`, `offsetForIndex()`/`indexAtOffset()` and the `lr-virtual-scroll`
  event are
  all expressed against the _same_ windowing math, so they agree with each other — but that math is
  estimate-based in `row-height="auto"` mode until the rows involved have been measured. Read a
  position after `await el.updateComplete`, and expect the value to converge rather than be final on
  the first frame.
- A sticky band only appears when `renderStickyGroup` _and_ at least one valid `groups` entry are
  both present; `groups` alone renders positioned markers with nothing pinned, and
  `renderStickyGroup` alone renders no overlay element at all.
- **A row that renders a popup needs the active-row lift, and this is why `[part='row']` has one.**
  Each row carries `will-change: transform` (a compositor hint for the per-frame translate), which
  makes every row its own stacking context. Rows otherwise carry no `z-index`, so they paint in DOM
  order and each one paints over the previous. Anything a row renders that overflows its own box —
  an `<lr-dropdown>` popup containing a row-action menu, a tooltip, an outward focus ring — is therefore painted
  _underneath_ every following row, no matter how high its own `z-index` is: that `z-index` only
  orders siblings inside the row's own context. The last row always looks correct, which is exactly
  why the failure tends to hide in short lists. A row lifts to `--lr-layer-content` while something
  inside it holds focus or while it contains an open `lr-dropdown`. The explicit dropdown-open branch covers
  imperative opening and virtual measurement/render cycles, where focus can temporarily return to
  the document while the popup remains visible. The value deliberately _matches_
  `[part='group']`'s rather than exceeding it, so the two land on the same layer and DOM order
  decides: groups render before the rows, so an active row wins while (and only while) it needs to,
  which is right — a group header is a non-interactive `pointer-events: none` label.

### Light-DOM row projection

`rowProjection="light"` exists for one shape: an application whose list rows are already styled by
its own global stylesheet, and which therefore could not adopt virtualization without rehoming a
dozen descendant rules per row into a new custom element or a growing set of custom properties. In
projection mode the windowed rows render into the host's own light DOM, so ordinary document CSS
reaches row content directly.

Positioning, measurement and semantics stay where they were. The `[part="row"]` wrapper remains in
the shadow root and keeps `position: absolute`, the per-frame `transform`, `role`, `aria-setsize`/
`aria-posinset` (or `aria-rowindex`) and the `ResizeObserver` box — document CSS cannot select it,
so consumer styles can never break windowing. The whole part vocabulary (`base`, `spacer`, `row`,
`group`, `sticky-group`) keeps matching in both modes, and `row-height="auto"` still measures
projected content because the light row is an ordinary in-flow child of that wrapper.

`projectedRows: HTMLElement[]` returns the projected light-DOM row wrappers in item order, and is
empty outside projection mode. The exported type is `LyraVirtualListRowProjection`; the reserved
attributes marking library-owned light-DOM nodes are exported as `VIRTUAL_LIST_ROW_ATTRIBUTE`
(`data-lr-virtual-list-row`) and `VIRTUAL_LIST_STICKY_ATTRIBUTE` (`data-lr-virtual-list-sticky`).

**Known gotchas, all inherent to handing the cascade back to the consumer:**

- **One component-owned wrapper sits between the host and your markup.** A slot cannot assign a text
  node or a multi-root fragment by attribute, so each row's content lives inside a wrapper carrying
  `data-lr-virtual-list-row`. Descendant selectors (`lr-virtual-list .row-title`) port unchanged;
  child combinators (`lr-virtual-list > .row`), `:nth-child`, `:first-child` and sibling combinators
  written against the unvirtualized markup do not. `:nth-child` on the wrappers reflects the current
  *window*, not the item index.
- **`closest('[part="row"]')` stops resolving.** A delegated listener on the host now sees an
  un-retargeted `event.target` inside the light DOM. Use `closest('[data-lr-virtual-list-row]')`.
- **The document cascade now reaches row content**, including resets and element-level rules that
  previously could not, so a projected row can look different from the same row in shadow mode.
- **Per-row light-DOM state does not survive a disconnect/reconnect.** Disconnect removes the
  projected rows completely (no rows, no markers, no anchor left behind), so a reparenting move
  rebuilds them. Scroll position, measurements and the window are unaffected — they live in
  component state, not in the rows.
- **Projection activates one task after hydration.** A server render has no DOM to project into, so
  the first window is shadow-rendered, hydration matches the server markup, and the rows then swap
  into the light DOM on the next task.
- **A row taken out of flow collapses its wrapper.** `position: fixed`/`absolute` or
  `display: none` on your own row leaves nothing for the wrapper to measure.

---
