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

# `lr-data-grid`

- **Import** `import '@aceshooting/lyra-ui/components/lr-data-grid.js';` (stable tag alias; registers the tag)
- **Class** `LyraDataGrid`, also available unregistered from `@aceshooting/lyra-ui/components/data/data-grid/data-grid.class.js`
- **Family** `components/data/` — see `llms/index.md` for its siblings
- **Status** `experimental` 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** 59 parts, 27 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-data-grid`

Virtualized client/server data grid with multi-sort, column filters, global search, grouping,
trees, row details, paging, pinning, resizing, reordering, selection, copy, and CSV export. Import
the granular registration module when the root bundle is not already loaded:

Repeated references to an admitted root row appear once in processed rows, pagination, facets, and
CSV, matching rendered canonical identity. The retained occurrence remains the original caller-owned
record. Clicking a supported interactive descendant, including a native control inside its open
shadow root, runs that control without also emitting `lr-cell-click`. Passive cell content continues
to activate the cell.

```js
import "@aceshooting/lyra-ui/components/data/data-grid/data-grid.js";
```

Give the grid an accessible name with `label` or a host `aria-label`; the host attribute wins.
Collection inputs are clone-owned readonly snapshots, so reassign `data`, `columns`, `groupBy`, and controlled
state arrays to update them; mutating the array originally assigned has no effect.
Column records are also copied and frozen synchronously. Row object
identities are preserved so formatter callbacks and `selectedRows` still refer to caller records.
Column identity uses a nonblank `id`, then a nonblank `field`, then stable definition-object
occurrence; malformed, blank, and later-duplicate identities are omitted first-wins. When `rowKey`
is set, malformed, blank, and later-duplicate row identities are omitted first-wins before
rendering, focus, selection, expansion, or events. Without `rowKey`, stable row-object occurrence
replaces positional identity, so reordering the same records does not transfer DOM or controlled
state to another row.

Pagination exposes the same page-local ARIA row model in client and server modes. The header is row
one; current-page display rows and expanded detail rows begin at two; and `aria-rowcount` covers
only that page plus the header. In server mode, `total` still drives `pageCount` and the pager, but
does not inflate `aria-rowcount` while `aria-rowindex` restarts for each loaded page. Treat these
ARIA values as page-local positions rather than as the dataset-wide total.

**Properties:**

- `appearance: 'outlined' | 'plain' = 'outlined'` (`appearance`, reflected).
- `childRows: string | ((row) => readonly Row[] | undefined) | null = null` (`child-rows`) — dot path or
  callback for nested rows. Tree sorting stays within each parent and paging keeps a subtree with
  its top-level parent. Projection is iterative, cycle-safe, and bounded to 10,000 total rows and
  64 descendant levels; exceeding a budget renders the localized `tree-limit` notice.
- `columnOrder: readonly string[] = []` (JS-only) — empty preserves declaration order.
- `columns: readonly DataGridColumn<Row>[] = []` (JS-only).
- `data: readonly Row[] = []` (JS-only) — client rows, or the currently loaded server page.
- `dataSource: ((request) => Promise<{ rows, total }>) | null = null` (JS-only) — providing it
  enables server behavior.
- `error: boolean = false` (`error`, reflected) — reports a failed load. The body's single row
  becomes the built-in failed-load `<lr-empty>` (matching `<lr-table>`'s own `error` contract),
  keeping the header/toolbar/pager mounted around it; `loading` beats `error` beats every
  empty/no-columns/no-results branch. Host-controlled, like `<lr-table>`'s: the internal
  `dataSource` request cycle's own `lr-data-error` does NOT set it (that event's contract keeps
  prior rows rendered on a rejection), so a consumer that wants a specific rejection to replace the
  row content sets `error = true` from its own `lr-data-error` listener.
- `errorHeading?: string` (`error-heading`) — failed-load heading override. Omitted localizes
  `<lr-table>`'s own `tableLoadFailed` default.
- `errorDescription: string = ''` (`error-description`) — failed-load supporting copy.
- `expandedRowKeys: readonly Array<string | number> = []` (JS-only).
  The mirrored `expandedKeys` spelling remains a compatibility alias for this same state.
- `filterDebounce: number = 250` (`filter-debounce`) — finite server search/filter delay.
- `filteredCount: number` (read-only, JS-only) — matching client rows before paging.
- `filterFromLeafRows: boolean = false` (`filter-from-leaf-rows`) — retains ancestors of matching
  tree descendants.
- `filters: readonly Array<{ readonly id: string; readonly value: unknown }> = []` (JS-only).
- `groupBy: string | readonly string[] | null = null` (`group-by`) — a string accepts comma- or
  whitespace-separated column ids/fields.
- `label: string | null = null` (`label`).
- `loading: boolean = false` (`loading`, reflected).
- `maxMultiSort: number = 0` (`max-multi-sort`) — zero means unlimited; the oldest sort is dropped
  when a positive limit is reached.
- `page: number = 0` (`page`, reflected) — zero-based.
- `pageCount: number` (read-only, JS-only).
- `pageSize: number = 20` (`page-size`).
- `pageSizeOptions: readonly number[] = [10, 20, 50, 100]` (JS-only). A finite current `pageSize`
  absent from this list is still inserted into the selector, keeping visible and IDL state aligned.
- `paginate: boolean = false` (`paginate`, reflected).
- `pinnable: boolean = false` (`pinnable`, reflected).
- `reorderable: boolean = false` (`reorderable`, reflected).
- `resizable: boolean = false` (`resizable`, reflected).
- `rowClass: ((row) => string | null | undefined) | null = null` (JS-only).
- `rowDetail: ((row) => string | TemplateResult | Node) | null = null` (JS-only).
- `rowKey: string | null = null` (`row-key`) — dot path for stable selection/expansion identity.
- `searchFn: ((value, term, row) => boolean) | null = null` (JS-only).
- `searchTerm: string = ''` (JS-only).
- `selectable: '' | 'single' | 'multiple' | 'none' = 'none'` (`selectable`, reflected) — a bare
  `selectable` attribute means `multiple`.
- `selectableRows: ((row) => boolean) | null = null` (JS-only).
- `selectedRowKeys: readonly Array<string | number> = []` (JS-only).
  The mirrored `selectedKeys` spelling remains a compatibility alias for this same state.
- `selectedRows: readonly Row[]` (writable, JS-only) — assigning rows that belong to the current source
  maps them to `selectedRowKeys`; detached rows are ignored and single-selection mode keeps the first.
- `selectionMode: 'none' | 'single' | 'multiple'` (`selection-mode`) — alias of `selectable` using
  `<lr-table>`'s `selectionMode`/`selection-mode` spelling for the same row-selection concept, so a
  consumer migrating between the two grid components doesn't need to remember two names.
  `selectable` remains canonical (mirrored from `wa-data-grid`); this alias reads and writes
  `selectable` directly, so there is no separate value to fall out of sync, and the bare `''`
  shorthand for `selectable`'s own `multiple` normalizes to `'multiple'` when read back through it.
- `server: boolean = false` (`server`, reflected).
- `size: 'xs' | 's' | 'm' | 'l' | 'xl' | 'small' | 'medium' | 'large' = 'm'` (`size`, reflected).
- `sort: readonly Array<{ readonly id: string; readonly desc: boolean }> = []` (JS-only).
- `sortDescFirst: boolean = false` (`sort-desc-first`).
- `striped: boolean = false` (`striped`, reflected).
- `total: number = -1` (`total`) — server total; `-1` derives from loaded/matching rows.
- `withColumnMenu: boolean = false` (`with-column-menu`, reflected).
- `withColumnsMenu: boolean = false` (`with-columns-menu`, reflected).
- `withoutSortRemoval: boolean = false` (`without-sort-removal`, reflected).
- `withSearch: boolean = false` (`with-search`, reflected).

`DataGridColumn<Row>` accepts `id`, dot-path `field`, `label`, `align`, numeric `width` /
`minWidth` / `maxWidth`, `flex`, `formatter(value, row)`, `cellTitle(row)`, computed `value(row)`,
`sortable`, `sortFn`, `comparator`, `sortDescFirst`, `sortUndefined`, `searchable`, `filterable`,
`filterType`, `filterFn`, `hidden`, `hideable`, `resizable`, `movable`, `pinnable`, `pinned`,
`footer`, `aggregation`, and `aggregatedFormatter`. A column with neither `field` nor `value` is an
action column: its formatter receives `undefined`, and it is not sorted or searched by default.
`cellTitle(row) => string | undefined` renders as the generated cell's native `title`, symmetrical
with `<lr-table>`'s `columns[].cellTitle` — e.g. the untruncated text behind an ellipsized cell, or
a formatted timestamp behind a relative one. Returning `undefined` or `''` omits the `title`
attribute entirely rather than rendering `title=""`, which would suppress an ancestor's own tooltip.

Built-in sort algorithms are `alphanumeric`, `alphanumericCaseSensitive`, `text`,
`textCaseSensitive`, `datetime`, and `basic`; `comparator` takes precedence. Built-in filter types
are `text`, `equals`, `number-range`, `date-range`, `set`, `includes-any`, and `includes-all`;
`filterFn` takes precedence in client mode. Group aggregations are `sum`, `min`, `max`, `mean`,
`median`, `count`, `unique`, `uniqueCount`, `extent`, or a callback.

**Methods:**

- `autoSizeColumn(columnId)` and `autoSizeColumns()` manage measured widths. `sizeColumnsToFit()`
  reserves visible effective zero-flex column widths and selection-control allocation before
  dividing the remaining width proportionally among flexible columns. Fixed widths are preserved;
  min/max clamps still apply, and an all-fixed grid is unchanged. Maximum clamps can leave unused
  space, while incompatible minimum widths can still overflow.
- `collapseAllRows()`, `collapseRow(key)`, `expandAllRows()`, and `expandRow(key)` update expansion
  without emitting the user-only row events.
- `copySelectedRows(options?: DataGridCopyOptions)` copies selected rows (or all processed rows
  when selection is empty) as TSV by default and returns the copied row count. Its options include
  `columnIds`, `includeHeaders`, `format: 'tsv' | 'csv'`, `escapeFormulas`, and `delimiter`; an
  explicit delimiter overrides the one normally selected by `format`. Clipboard settlement is
  asynchronous: `lr-copy` fires only after the owning context's write fulfills (including a
  successful legacy textarea fallback), while rejection emits `lr-error` plus `lr-copy-error` and
  announces the localized `copyFailed` string. Copy intent alone is never reported as success.
- `exportDataAsCsv(options?: DataGridExportOptions)` downloads formula-safe delimited data (CSV by
  default); `getDataAsCsv(options?: DataGridCsvOptions)` returns it without downloading. Options include `delimiter`,
  `includeHeaders`, `columnIds`, `escapeFormulas`, and `fileName`. The Lyra-only `columns` and
  `filename` aliases were **removed in 9.0.0** — rename them to `columnIds` and `fileName` (the
  spellings `wa-data-grid` uses); an unrenamed call silently falls back to every visible column and
  `data.csv`. Formula escaping is on by default for string cells beginning with `=`, `+`,
  `-`, or `@`; numeric values remain numeric.
- `focus(options?)` focuses the current roving header/cell stop.
- `getColumnFacets(columnId)` returns `{ uniqueValues: Map, minMax? }`, computed after every other
  filter but before the named column's own filter. Server mode returns an empty map.
- `getColumnPin(columnId)`, `pinColumn(columnId, side: 'left' | 'right' | 'start' | 'end' | false)`, and
  `toggleColumn(columnId, visible)` read or change column state without emitting the corresponding
  user-only events. `left`/`start` mean logical inline-start and `right`/`end` mean logical
  inline-end, so both pairs mirror under RTL; pass `false` to unpin.
- `getProcessedRows()` returns a frozen readonly snapshot of matching sorted rows before paging;
  `getVisibleRows()` returns a frozen readonly snapshot of the current effective page.
- `getState()`, `setState(state)`, `resetState()`, and `resetColumns()` serialize or restore view
  state. `getState()` is detached, frozen, and JSON-safe (Set filter values become arrays). Unknown
  column ids are ignored. `resetState()` intentionally preserves selection, page, and page size.
- `handleColumnsChange()`, `handlePageChange()`, and `handleSearchTermChange()` are public handler
  seams used by the built-in controls.
- `reload()` forces the current server request.
- `scrollToIndex(index, options?: DataGridScrollOptions)` addresses a processed row, maps it through
  group/tree/detail display rows, and scrolls with `align: 'start' | 'center' | 'end'`. A row hidden
  in a collapsed branch is a no-op.

**Server mode:** `dataSource` receives `{ sort, filters, search, page, pageSize, signal }`. A newer
request aborts and supersedes the previous one; rejected requests keep prior rows and emit
`lr-data-error`. Filter/search requests use `filterDebounce`; sorting and paging load immediately.
For event-driven loading, set `server`, listen to the mirrored `request` event, and assign `data`,
`total`, and `loading` yourself. The redundant Lyra-only `lr-data-request` event was removed in
9.0.0; rename that listener to `request`. Each dispatch carries a fresh frozen detail that cannot
mutate the component or loader request.

**Keyboard:** headers and cells share one roving grid stop; the scrollable `body` is separately
focusable so keyboard users can pan overflowing content. Arrow keys traverse cells, Home/End
traverse a row, Ctrl+Home / Ctrl+End reach grid ends, PageUp/PageDown move a page, Enter
sorts/activates, Space selects, Shift+Arrow reorders a header, Alt+Arrow resizes from the header,
unmodified Left/Right adjusts a focused separator, Ctrl+A selects
the current page, Ctrl+C copies, and Shift+F10 requests a cell context menu. Inline-direction
movement swaps under RTL. Keyboard events from an interactive formatter descendant remain owned by
that descendant.

**Events:** `request`; `lr-cell-click` and cancelable `lr-cell-contextmenu` carry canonical
`rowKey`/`columnId` alongside the row, column, value, and display index (canceling the latter suppresses the
native menu); `lr-column-move`; `lr-column-pin`; `lr-column-resize` (`detail.finished` distinguishes
live and committed resize). `pointerup` commits a pointer drag. `pointercancel` or lost capture
restores the exact pre-gesture width state; after a live move it emits the restored width with
`finished: false`, and it never emits a canceled `finished: true` commit. `lr-column-visibility-change`;
`lr-data-error`;
`lr-filter-change`; `lr-page-change`; `lr-row-collapse`; `lr-row-expand`; `lr-group-collapse` and
`lr-group-expand` (frozen `{ key, columnId, value, rows }` snapshots); `lr-row-select` with
canonical `{ selectedRowKeys, selectedRows }` plus mirrored `selectedKeys`; row expand/collapse
details use canonical `rowKey` plus mirrored `key`; cancelable `lr-sort-request` (frozen readonly
`detail: { sort }`) precedes `lr-sort-change`; vetoing it leaves `sort` unchanged and suppresses
`lr-sort-change`, mirroring `<lr-table>`'s identical `lr-sort-request`/`lr-sort` veto-then-commit
contract;
`lr-copy` (frozen `{ ok: true, text }` after fulfillment); `lr-copy-error`
(frozen `{ ok: false, text, reason, error }` after failure); `lr-error` (compatibility failure
notification with no raw platform error text); `lr-data-error` does NOT itself set the built-in
`error` state (see `error` above); `lr-retry` (`detail: null`, cancelable) — the built-in
`[part='retry-button']` was activated, only rendered while `error` is set; the default action
clears `error`, `preventDefault()` leaves it set instead. Every library event bubbles and is
composed; only `lr-cell-contextmenu`, `lr-sort-request`, and `lr-retry` are cancelable. Structured
details and their owned collections are frozen. The toolbar search and active column-filter inputs
re-dispatch `focus` and `blur` once from the grid host as bubbling, composed native `FocusEvent`s,
preserving `relatedTarget` so delegated ancestors can observe editor entry and exit without
crossing the shadow boundary.

**Slots:** `empty`, `loading`, `no-results`, `error` (replaces the built-in failed-load state,
including its retry button, while `error` is set).

**CSS parts:** `body`, `cell`, `column-menu`, `column-menu-button`, `columns-menu`, `data-grid`,
`drag-ghost`, `ellipsis`, `empty`, `error-row` (the single full-width row that replaces the body
content while `error` is set), `error-cell`, `error` (the built-in `<lr-empty>` host), `error-base`,
`error-icon`, `error-heading`, `error-description`, `error-actions` (all four exported from the
built-in error `<lr-empty>`'s own parts), `retry-button` (the built-in retry control), `expand-button`,
`filter-button`, `filter-panel`,
`filter-panel-clear` (clears the active column filter editor, replacing the native search-cancel
glyph the component resets; rendered only while it has a value), `first-button`,
`first-icon`, `footer`, `footer-cell`, `footer-row`, `group-count`, `group-row`, `group-value`,
`header`, `header-cell`, `last-button`, `last-icon`, `live-region`, `loading-overlay`,
`next-button`, `next-icon`, `no-results`, `page`, `page-current`, `page-size`, `pager`,
`pager-button`, `pin-indicator`, `previous-button`, `previous-icon`,
`resize-handle`, `row`, `row-detail`, `search`, `search-wrapper` (the row wrapper around `search`
and `search-clear`), `search-clear` (clears the global row-search input, replacing the native
search-cancel glyph the component resets; rendered only while it has a value),
`select-all-checkbox`, `sort-indicator`,
`sort-number`, `table`, `toolbar`, `tree-limit`.

Each per-column disclosure opens an honestly named native-control `group`, not a false ARIA menu:
its buttons have localized pin-to-start, pin-to-end, and unpin names; its visibility toggle has one
native checkbox semantic owner; and `aria-controls` links trigger and group. The per-column group,
column-filter panel, and all-columns visibility group are mutually exclusive and use the shared
topmost overlay router, so Escape closes only the active disclosure and returns focus to its own
trigger. Resize separators are focusable and expose finite `aria-valuemin`, `aria-valuemax`, and
`aria-valuenow`; inverted bounds collapse to the minimum. Revoking resize/reorder capability during
a gesture rolls it back, and column drops require the current grid's owned drag token plus current
source/target capability.

The four pager navigation controls each wrap their glyph in an icon part — `first-icon`,
`previous-icon`, `next-icon`, `last-icon` — rendered as real chevron SVGs rather than literal
`«`/`‹`/`›`/`»` text, so they mirror under `dir="rtl"` instead of pointing the wrong way. `first-icon`
and `last-icon` hold two overlapping chevrons so the pair reads as one doubled glyph. This matches
`<lr-pagination>`'s identical treatment; style the glyph through the icon part and the control
through `first-button`/`previous-button`/`next-button`/`last-button` (or the shared `pager-button`).

`[part="live-region"]` is a visually-hidden, `aria-hidden` **mirror** of the last polite
announcement — a styling and inspection surface, with no live-region role of its own. The
announcement itself goes to the library's shared **light-DOM** polite region, appended to the
consumer's `<body>` and marked `data-lr-live-region="polite"`, because a live region inside a
shadow root is not reliably announced (JAWS with Firefox ignores one outright). Assert against that
document-level region rather than `::part(live-region)`; the part still tells you what the grid
last announced.

Declarative `loading` is silent on mount. Each later `false` → `true` transition appends the
localized loading text to that shared polite sink, including repeated loading cycles. The visible
`loading-overlay` is ordinary non-live content, and the grid exposes the state with `aria-busy`.

**Themeable custom properties:** `--accent-color`, `--background-color`, `--border-color`,
`--border-radius`, `--border-width`, `--cell-padding`, `--focus-ring`, `--header-background`,
`--header-row-height`, `--header-text-color`, `--indent-size`, `--max-height`, `--row-height`,
`--row-hover-background`, `--selected-background`, `--stripe-background`, `--text-color`, and
`--transition-duration`. Defaults resolve through Lyra design tokens. Set `--max-height: none` to
render every row instead of a virtual window. Three grid-specific hooks reach formatter and row
detail content inside the shadow root: `--lr-data-grid-cell-color` (default `inherit`) controls
body-cell text, `--lr-data-grid-cell-link-color` (default `var(--lr-color-brand)`) controls nested
anchors, and `--lr-data-grid-cell-link-hover-color` (default
`var(--lr-data-grid-cell-link-color, var(--lr-color-brand))`) controls those anchors on hover,
focus-visible, and active interaction. Set the link color to `revert` to restore the user-agent
default. Six independent interaction-background hooks preserve those state boundaries:
`--lr-data-grid-control-hover-background` and `--lr-data-grid-control-active-background` theme
search, toolbar, pager, and resize controls; `--lr-data-grid-page-size-active-background` themes
the page-size selector when pressed; `--lr-data-grid-row-active-background` themes pressed data
rows; and `--lr-data-grid-sortable-header-hover-background` and
`--lr-data-grid-sortable-header-active-background` theme sortable header states. They default to
live `color-mix()` values of the effective grid accent and transparent, using the corresponding
`--lr-color-mix-hover` or `--lr-color-mix-active` token (the page-size pressed state intentionally
uses the hover mix), so an accent override remains coherent while each surface can still be
overridden independently.

```html
<lr-data-grid
  label="Engineering roster"
  row-key="id"
  selectable
  with-search
  paginate
  page-size="20"
></lr-data-grid>
<script type="module">
  const grid = document.querySelector("lr-data-grid");
  grid.columns = [
    { field: "name", label: "Name", filterable: true },
    { field: "score", label: "Score", align: "end" },
  ];
  grid.data = [{ id: 1, name: "Ada", score: 97 }];
</script>
```

**Known gotchas:** selection and expansion are only stable across sort/filter/server page changes
when `rowKey` names a unique string/number field. CSV/copy uses a formatter only when it returns a
string; templates and Nodes fall back to the raw value. Server export/copy includes currently loaded
rows because the browser does not possess unloaded pages.

---
