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

# `lr-table`

- **Import** `import '@aceshooting/lyra-ui/components/lr-table.js';` (stable tag alias; registers the tag)
- **Class** `LyraTable`, also available unregistered from `@aceshooting/lyra-ui/components/data/table/table.class.js`
- **Family** `components/data/` — 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** 46 parts, 23 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-table`

Sort/select-aware data table with a bounded 100-row default projection. A sortable header first
emits cancelable `lr-sort-request`; accepted transactions emit `lr-sort` with the same canonical
`sortKey`/`sortDir`. Client mode updates sort state and orders rows; server mode leaves sort state
controlled. Optional filtering, bounded pagination, and loading chrome are built in. A
`columns[].heatValue`-opted-in heat-tint mode paints a shared, normalized color-mix background across
every tinted cell (auto-derived domain, or overridden via `heatTintScale`); `rowTotal`/`grandTotal`
add a trailing totals column mirroring `expandedContent`'s leading one — `rowTotal(row)` renders
per-row, `grandTotal(rows)` renders at its intersection with the footer row — both sharing `footer`'s
own "consumer computes/renders" contract rather than assuming addition.

Effective locale changes recollate and filter the current view while keeping activation and edit
lookups aligned with the rendered page. Direct row focus uses the same stable-key restoration rules;
programmatic locale changes do not emit a row action.

Header cells and body rows use separate roving tab stops. When a controlled `columns` or rendered
row collection changes while one of those stops owns focus, the table keeps the same stable key if
it survives and otherwise clamps focus to the nearest surviving index. Moving focus outside the
table before the update is applied always wins; nested editors and controls keep their independent
focus contracts.

**9.0 migration:** replace `selectedKey = key` with `selectedRowKeys = new Set([key])`; both single
and multiple modes now use that one store. Replace `columnsHidden` with read-only
`hasHiddenPriorityColumns`, `showAllColumns` with `priorityColumnsVisible`, and the two former
column-visibility events with `lr-priority-columns-visibility-change { visible }`. Column
`sticky: true` becomes `sticky: 'start'`; `editable: true` becomes
`editTrigger: 'double-click'`, and `editable: 'always'` becomes `editTrigger: 'always'`. Sort
listeners now receive phased readonly `{ phase, sortKey, sortDir }` details from
`lr-sort-request`/`lr-sort`. A bare table now projects 100 rows per page (with inputs bounded to
1..500); set an explicit finite `page-size` when a different window is required.

**TypeScript:** `LyraTable<T, K extends string | number = string | number>` takes a second type
parameter for the row-key type. `K` types `rowKey`'s return value,
`selectedRowKeys`/`expandedRowKeys`, and every event detail's `rowKey`/`rowKeys`, so
`LyraTable<Row, number>` reads `event.detail.rowKey` as `number` with no cast. It defaults to the
`string | number` union, so an untyped element and an existing `LyraTable<Row>` annotation compile
unchanged.

**Properties:**

- `columns: readonly TableColumn<T>[] = []` (attribute: false; clone-owned frozen collection,
  bounded to the first 10,000 source positions; blank keys and later duplicates are omitted first-wins
  before header, cell, sort, focus, and event paths; reassign to update) — `{ key, label,
headerCell?, width?, minWidth?, maxWidth?,
resizable?, sortable?, sortValue?, defaultSortDir?: 'asc'|'desc', align?: 'start'|'end',
priority?: 'medium'|'low',
sticky?: 'start'|'end', editTrigger?: 'double-click'|'always', editValue?, editLabel?: (row) => string,
editType?: 'text'|'number'|'select',
editOptions?: { value: string; label: string }[], footer?, cellStyle?, heatValue?,
cell: (row) => unknown }` — `cell` is required for every `editTrigger` except `'always'`, whose
  persistent editor renders unconditionally so the table's render path never falls back to it —
  `sortValue(row) => string | number | null | undefined` supplies the comparable value backing
  client-mode sorting for that column: a finite number sorts numerically, a string sorts through an
  `Intl.Collator` built from the component's effective locale with `numeric: true` (so `item2`
  precedes `item10`), and `null`/`undefined`/non-finite sorts **last in both directions** so
  flipping `sortDir` never floats a block of blanks to the top. Omit it and the column sorts by its
  stringified `cell()` output instead — meaningful only when `cell()` returns a string or number, so
  define `sortValue` whenever `cell()` returns a template or element. Ignored under
  `sortMode="server"` and on a column that is not `sortable`;
  `defaultSortDir` sets this column's own initial sort direction the first time header activation
  makes it the active `sortKey`, taking precedence over the element-level `defaultSortDir` (see
  below), which remains the fallback when this is omitted — useful for giving one column in an
  otherwise-ascending table (e.g. a "last updated" column) the opposite starting direction.
  Re-activating a column that is already `sortKey` still only toggles between `'asc'` and `'desc'`,
  exactly as the element-level `defaultSortDir` already does;
  `priority` progressively hides that column once `[part='base']`'s content actually overflows it —
  `'low'` hides first, and `'medium'` hides too if the table would still overflow with just `'low'`
  gone — measured via the same `ResizeObserver`-driven overflow check `scroll-mode="auto"` uses, never
  at a fixed container width (there is deliberately no themeable-token form of this: a `@container`
  query, which is what a token-driven threshold would need, can only ever read ancestor inline-size,
  never a measured overflow amount), reversible via
  `[part='reveal-columns-button']`; `sticky` pins that column's header/cells to the logical start or
  end edge while the table scrolls horizontally — multiple sticky columns stack
  in logical order (each measures every earlier sticky column's rendered width via
  `--lr-table-sticky-offset`) instead of overlapping at the same edge. A sticky body cell's
  background always matches its own row's effective fill — striped, selected, hovered, or pressed —
  rather than painting a flat opaque surface over that state; a sticky header cell is unaffected
  (headers are never striped/selected) and keeps its plain surface fill, see
  `--lr-table-row-selected-bg`/`--lr-table-row-stripe-bg` below; `footer` renders a
  sticky-bottom footer cell for that column, computed from every currently-rendered row (post-sort,
  pre-pagination) — e.g. a column total — omit it for a column with no footer value, and a
  `[part='foot']` (`<tfoot>`) only renders at all when at least one column defines `footer`;
  `heatValue(row) => number | null | undefined` opts a column into heat-tint mode: its presence on
  any column is the opt-in signal (no separate boolean), a `null`/`undefined` return excludes that
  cell from both the domain and the tint ("no data", not "zero"), and every `heatValue`-defining
  column shares one normalized `color-mix()` background scale (see `heatTintScale` below) painted
  via the retheme-able `--lr-table-heat-tint-lo`/`-hi` custom properties, matching `lr-heatmap`'s
  own ramp-token convention; `cellStyle` is applied directly to the generated `<td>` via `styleMap` — e.g. a computed heat-tint
  background a `cell()`-returned inner element can't paint into the cell's own padding — omit it for
  no per-cell style override (the default, unchanged output);
  `editTrigger: 'double-click'` opens a native editor on that cell's double-click, `F2`, or `Enter`
  (one cell at a time), while `'always'` renders a persistent editor in every body cell from first paint, for a
  settings/rate-style column meant to be typed straight into — while `editValue` supplies the editor
  value and `editType` selects `'text'`, `'number'`, or `'select'` (a native `<select>` populated
  from `editOptions: { value: string; label: string }[]`, one `<option>` per entry in order; a
  `'select'` column with no `editOptions` renders an empty, valueless `<select>` instead of
  throwing); `editLabel(row) => string`, read once per row exactly like `editValue`/`cellTitle`,
  overrides that editor's accessible name — omit it and every editor in the column shares the same
  interpolated `tableEditCell` name instead (see the accessibility note under `editTrigger` below)
  `cellTitle(row) => string | undefined` is the `title` analogue of `cellStyle`, applied directly to
  the generated `<td>` — e.g. the untruncated text behind an ellipsized cell, or a formatted
  timestamp behind a relative one;
  `resizable` adds a focusable separator `[part='resize-handle']` and emits `lr-column-resize` with
  the live width in CSS pixels. Drag it, or use logical ArrowLeft/ArrowRight for 10px steps (mirrored
  under RTL), Shift+Arrow for 50px steps, Home for the minimum, and End for an explicit pixel
  `maxWidth`; explicit pixel `minWidth`/`maxWidth` values bound both input paths. The separator
  exposes its current/minimum/bounded-maximum pixel width through ARIA value attributes. Only the
  _commit_ is vetoable — see `lr-column-resize` under Events.
- **`cellStyle` beats `heatValue`, always.** `styleMap` writes an inline `style=` attribute, and an
  inline style outranks any stylesheet rule in the cascade regardless of specificity, while the heat
  tint is painted by a shadow-stylesheet rule. So a `cellStyle` returning
  `background`/`backgroundColor` on a column that also defines `heatValue` silently and completely
  erases that column's tint — no warning, and the cell still contributes to the shared domain, so the
  _other_ tinted columns' scale shifts around a cell that shows no tint at all. Define both on one
  column only when that override is the intent; to tint _and_ style, return only non-background
  declarations (`color`, `fontWeight`, `textAlign`, …) from `cellStyle`.
- `columns[].editTrigger: 'double-click' | 'always'` — `'double-click'` opens one transient editor;
  Enter commits and closes, Escape cancels and closes, and blur-after-change commits. `'always'`
  renders an editor in
  every body cell of that column, permanently:
  - **Keyboard entry (`'double-click'` columns).** The resting (not-currently-editing) cell of a
    `'double-click'` column is its own roving-focus stop — `tabindex="-1"`, carrying `[data-editable]`
    — reachable once the row itself has focus: ArrowRight (ArrowLeft under RTL) enters at the first
    editable cell in the row and steps forward through any further ones; ArrowLeft (ArrowRight under
    RTL) steps back and, from the first editable cell, returns focus to the row. None of this is a Tab
    stop — a table with a `'double-click'` column gains no new entry in the page's Tab order, only a
    new arrow-reachable one — and a table with no `editTrigger` column at all renders no `tabindex`/
    `[data-editable]` anywhere. `F2` or `Enter` on the focused cell opens its editor; `Enter` on the
    row itself (no cell focused) still only activates the row. `editCell(rowKey, columnKey)` (see
    Methods above) is the same open action as a public method. Closing the editor, by commit or by
    cancel, returns focus to the cell that opened it.
  - **Focus model (`'always'` columns).** Each editor is a plain tab stop — no `tabindex` of its own
    — exactly like the
    existing row-expand toggle, and stays _outside_ the header/row/cell roving-tabindex model. Tab
    walks
    down the column; arrow keys still navigate the grid from a row's own roving stop, and act as
    ordinary caret movement once focus is inside a field. Non-editable columns are unaffected.
  - **Value binding.** A persistent `'text'`/`'number'` editor binds its `value` as a **content
    attribute**, not as the `.value` property, so native dirty-value-flag semantics apply.
    Trade-off: once the user has typed into a cell, an out-of-band `rows` update to that same cell
    will **not** visibly replace their draft. An editor the user has not touched still picks up a
    new `rows` value normally. A persistent `'select'` editor has no equivalent protection —
    `<select>`/`<option>` carry no native dirty-value flag, so an out-of-band `rows` update to that
    cell re-applies the selection even after the user has picked a different, uncommitted option.
    `lr-cell-edit` remains the only mutation channel — the table never mutates `row`.
  - **Keys.** Enter commits (emits `lr-cell-edit`) and _keeps focus_ in the field, since there is no
    closed state to fall back to. `change` (blur after a modification) commits in both modes.
    Escape is **not** cancelled and does nothing to the editor — there is nothing to cancel back to
    — so an ancestor dialog/popover still acts on it.
  - **Focus across re-sorts and pagination.** Rows are keyed, so a re-sort _moves_ the editor's
    `<input>` (the typed value rides along) and the table restores focus to the same logical cell
    afterwards. If the focused row leaves the rendered page entirely (pagination, filtering), focus
    is simply lost rather than yanked to whichever unrelated row now sits in that position.
  - **Accessible name.** Each editor's name is `columns[].editLabel(row)` when the column defines
    it, or otherwise the interpolated `tableEditCell` string (`Edit {column}`) — identical for every
    row in that column, since the default has no row context. That default is adequate for
    `'double-click'`, where at most one editor is ever open, but not for `'always'`: every row's
    editor there is a permanent, individually focusable Tab stop, so leaving `editLabel` unset on an
    `'always'` column exposes as many identically named controls as there are rows (e.g. fifty "Edit
    Status" comboboxes), failing WCAG 2.4.6 and 1.3.1. Define `editLabel` for any `'always'` column
    to give each row's editor its own name.
- `hasHiddenPriorityColumns: boolean = false` (attribute `has-hidden-priority-columns`, reflected) —
  computed/read-only and true only while a priority column is actually hidden. It becomes false
  when `priorityColumnsVisible` reveals the columns. Measured via a `ResizeObserver` on
  `[part='base']` plus a post-render DOM check, so it settles one render cycle after a `columns`/
  `rows`/width change lands — poll for the settled value (e.g. `await el.updateComplete;` twice, or
  `waitUntil()`) rather than assuming a single `updateComplete` covers it. Setting it directly has no
  lasting effect; it is recomputed on the next render or resize. The toggle remains available while
  a narrow table is revealed, even though this property truthfully reports false
- `rows: readonly T[] = []` (attribute: false; clone-owned frozen collection bounded to the first
  10,000 rows; reassign to update). Records are retained
  here; one canonical `rowKey` projection omits blank and later-duplicate identities first-wins
  before filtering, counts, pagination, focus, actions, and events
- `layout: 'auto'|'fixed' = 'auto'` (reflected) — a **floor** on the `<table>`'s `table-layout`, not
  an override. `'fixed'` forces the fixed algorithm even when no column declares a `width`, so every
  column shares the available width evenly and long cell content is clipped/wrapped instead of
  stretching its column. The default `'auto'` still _resolves_ to `fixed` whenever a column declares
  a `width`, a column has been drag-resized, or a resize gesture is in flight — column resizing does
  not work under `table-layout: auto`, so `'auto'` can never mean "never fixed". See the gotchas for
  the two consequences of the fixed algorithm worth knowing before opting in
- `sortKey: string = ''` (attribute `sort-key`)
- `sortDir: 'asc'|'desc' = 'asc'` (attribute `sort-dir`)
- `sortIndicators: TableSortIndicators = 'active'` (attribute `sort-indicators`) — `'all'` adds a
  muted bidirectional indicator to each inactive sortable header, reserving the same space as the
  active chevron. The default retains only the active column's indicator. This presentation does
  not change keyboard interaction, client/server sorting, or `aria-sort="none"` on inactive columns.
- `sortMode: 'client'|'server' = 'client'` (attribute `sort-mode`, reflected) — mirrors
  `paginationMode`'s identical split. `'client'` (the default) orders `rows` in the browser from
  `sortKey`/`sortDir` and the active column's `sortValue`; `'server'` renders `rows` exactly as
  given, for a table whose ordering already happened server-side. Accepted header activation emits
  `lr-sort` under both modes, but only client mode mutates `sortKey`/`sortDir`. With no `sortKey` set
  (the default) `'client'` is a **no-op** — the
  input order is preserved verbatim — so a table that never sorts renders identically either way.
  Sorting applies to the whole filtered set _before_ client pagination slices the page, so page 1
  holds the globally-smallest rows rather than a re-sorted slice
- `defaultSortDir: 'asc'|'desc' = 'asc'` (attribute `default-sort-dir`) — the direction applied
  whenever header activation switches sorting to a **different** column, including the first column
  ever sorted, for any column that does not declare its own `columns[].defaultSortDir` (that
  column-level value wins first when set — see `columns` above). Re-activating the column that is
  already `sortKey` toggles between `'asc'` and `'desc'` instead, so `defaultSortDir` never
  overrides a direction the user just chose for the column they are still on. Set `'desc'` for a
  most-recent-first or highest-first table
- `viewRows: readonly T[]` (readonly; computed, no attribute) — `rows` after filtering and
  (client-mode) sorting, ignoring pagination — the same set `columns[].footer(rows)`/
  `grandTotal(rows)`/the heat-tint domain already compute over. Read this instead of
  re-implementing filtering/sorting in a consumer that needs "what the grid currently shows" (e.g.
  to export it). A fresh, frozen array on every read: mutating the result cannot reach or corrupt
  the table's own internal state
- `pageRows: readonly T[]` (readonly; computed, no attribute) — `viewRows` sliced to the page
  currently rendered in `<tbody>`. Same defensive-copy guarantee as `viewRows`
- `rowKey?: (row: T) => K` (attribute: false) — derives each row's stable identity for
  DOM-reconciliation and the delegated row click/keydown lookup; falls back to the row's array index
  when omitted, which is only safe while `rows` never reorders — set it whenever `rows` can be
  sorted/filtered/re-ordered across renders, or selection/click can silently attach to the wrong
  row. Empty string identities and later duplicates are omitted; the first valid occurrence wins.
  The element's second type parameter (`K`, default `string | number`) is this callback's return
  type
- `selectionMode: 'none'|'single'|'multiple' = 'none'` (attribute `selection-mode`, reflected) —
  opt-in self-managed row selection; the default remains presentational
- `selectedRowKeys: ReadonlySet<string | number> = new Set()` (attribute: false) — the single selection
  store in every mode, bounded to 10,000 keys. Single mode enforces at most one key; multiple mode
  toggles membership. Malformed and whitespace-only string keys are omitted, while valid
  unmatched/off-page keys remain controlled state for server pagination. Reads return immutable detached `ReadonlySet`
  facades; reassign a new set to update
- `filterable: boolean = false` (attribute `filterable`, reflected) — renders a localized search
  field above the grid
- `filterText: string = ''` (attribute `filter-text`) — controlled filter text. Removing the
  attribute clears the effective filter safely while retaining native removal readback; a later
  supplied value filters normally.
- `filter?: (row: T, text: string) => boolean` (attribute: false) — typed predicate used by the
  filter field; when omitted, rows are matched against their JSON representation
- `filterLabel?: string` (attribute `filter-label`) and `filterPlaceholder?: string`
  (attribute `filter-placeholder`) — omission localizes `tableFilterLabel`/
  `tableFilterPlaceholder`; any supplied string, including the built-in English text or `''`, is
  an explicit verbatim override
- `spellcheck: boolean = true`, `autocapitalize: string = ''`, `autoCorrect: string = ''`
  (attribute `autocorrect`) — forwarded to the filter input and, for a `'text'` (the default)
  `editType`, the inline cell editor; no effect on a `'number'` or `'select'` cell editor. `spellcheck="false"`
  is parsed as `false` via a string-aware converter (Lit's default presence-based boolean
  converter would otherwise treat any attribute value, including the literal string `"false"`, as
  `true`).
- `loading: boolean = false` (attribute `loading`, reflected) and `loadingLabel?: string`
  (attribute `loading-label`) — renders busy chrome and suppresses the real rows while loading;
  omission localizes `tableLoading`, while a supplied string (including `''`) renders verbatim
- `loadingAppearance: 'spinner'|'skeleton' = 'spinner'` (attribute `loading-appearance`, reflected) —
  how `loading` renders. `'spinner'` replaces the whole grid with an indeterminate spinner.
  `'skeleton'` instead renders the real table — the same `<colgroup>` (declared _and_ drag-resized
  widths included), the same `<thead>`, the filter field and the pagination footer — and fills
  `<tbody>` with placeholder rows, so a cold load sketches the grid's shape rather than collapsing to
  a spinner and reflowing when the rows land. Kept as a separate property rather than widening
  `loading` to a string union, so `?loading=${…}` bindings and `el.loading === true` checks keep
  working. Loading takes precedence over both empty branches. When `columns` is empty, a skeleton
  request temporarily falls back to the spinner because there is no schema to sketch; the table
  does not flash its no-columns empty state while `loading` remains true
- `skeletonRows: number = 0` (attribute `skeleton-rows`) — placeholder row count under
  `loadingAppearance="skeleton"`. `0` renders 3 placeholders for the ordinary bounded default, or
  derives a non-default explicit `pageSize` (capped at 20). Positive explicit values are also capped
  at 20. Ignored entirely under the default spinner appearance
- `pageSize: number = 100` (attribute `page-size`) — normalized to `1..500`; a bare table therefore
  never mounts an unbounded row-by-column projection
- `page: number = 1` (attribute `page`, reflected) — self-managed after accepted client pagination;
  controlled in server mode
- `totalItems: number = -1` (attribute `total-items`) — server-side total item count; `-1` derives
  the total from filtered rows
- `paginationMode: 'client'|'server' = 'client'` (attribute `pagination-mode`, reflected) — client
  mode slices rows and updates `page`; server mode leaves `page` controlled and bounds the supplied
  page to `pageSize`
- `unknownTotal: boolean = false` (attribute `unknown-total`, reflected) — server pagination for a
  caller with no total item count, only whether one more page exists (`hasNext`). Forwarded to the
  nested `<lr-pagination>` as its own indeterminate mode (`total="-1"`): previous/next only, no
  numbered page list, no item-range summary. Ignored outside `paginationMode: 'server'` — client
  mode always knows the exact row count it slices. A dedicated boolean rather than reusing
  `totalItems`'s own `-1` sentinel, which already means "derive from the currently matching rows"
- `hasNext: boolean = true` (attribute `has-next`, reflected) — whether at least one more page
  exists past the current one; consulted only alongside `unknownTotal` and forwarded verbatim to
  the nested `<lr-pagination>`'s own `hasNext`
- Editable columns emit `lr-cell-edit` on commit and never mutate the supplied row object.
- `groupBy?: (row: T) => string | number` (attribute: false) — inserts a non-focusable full-width
  group row wherever this key changes between consecutive rendered rows. Supply `rows` with each
  group already contiguous; the table renders the groups in their first-appearance order in `rows`
  and never reorders them. A client-mode sort is applied **within** each group, so sorting a
  grouped table on a column unrelated to the group key reorders rows inside their groups and
  leaves the grouping intact. The one exception: when the sorted column's value is constant inside
  every group — the group column itself, most obviously — there is nothing to reorder within a
  group, so the **groups** are ordered by that value instead.
- `groupLabel?: (key: string | number, rows: readonly T[]) => unknown` (attribute: false) — custom group
  header content; without it, the group key is rendered as text
- `expandedContent?: (row: T) => unknown` (attribute: false) — enables a leading expand toggle and
  renders a full-width detail row beneath expanded records. The returned content renders inside
  the component's shadow root, behind the `expanded-cell` part — page CSS cannot reach it, and
  `::part(expanded-cell)` only reaches the wrapping `<td>` itself, not the descendants this
  callback returns (the same `::part()` limitation a column's `cell(row)` anchors run into, see
  `--lr-table-cell-link-color` below). Style such content by returning already-styled elements —
  inline `style`, or elements that reference this table's own `--lr-*` design tokens, which
  inherit across the shadow boundary like any custom property. When script has to reach the
  rendered panel anyway, `expandedContentElement(rowKey)` (below) resolves that `<td>`;
  `rowElement(rowKey)` does not, because the panel is a sibling `<tr>` rather than part of the row
- `canExpand?: (row: T) => boolean` (attribute: false) — optional per-row gate for expansion
- `rowExpandLabel?: (row: T, expanded: boolean) => string | undefined` (attribute: false) —
  accessible name for one row's expand/collapse chevron, read once per render for that row,
  exactly like a column's `editLabel`/`cellTitle`; consumer-owned text used verbatim, never
  passed through localization. Omit it and every chevron shares the same localized
  `expand`/`collapse` name, which carries no row context — fine for a handful of rows, but each
  chevron is its own Tab stop, so a long table otherwise announces the same two names over and
  over with no way to tell the rows apart. There is no default row context to derive one from
  here: this component has no row-header notion (`rowKey` is an opaque identity, not a label)
- `expansionMode: 'none'|'single'|'multiple' = 'none'` (attribute `expansion-mode`, reflected) —
  mirrors `selectionMode` member for member, for expansion. The default `'none'` keeps
  `expandedRowKeys` fully consumer-controlled: an activation only reports `lr-row-expand-toggle`.
  `'single'` and `'multiple'` self-manage the set behind the cancelable `lr-row-expand-request`, so
  an expandable table needs no host handler at all; `'single'` keeps at most one row open and
  coerces an already-larger `expandedRowKeys` down to its first key when this property becomes
  `'single'` (no event for that fix-up — read `expandedRowKeys` back). Closing a row to make room
  for another is an ordinary expansion change, so `'single'` reports the displaced row with its own
  `lr-row-expand-toggle` (`expanded: false`) immediately **before** the accepted one — the one
  exception being a displaced row that is filtered or paged out of view, which has no `row` object
  for the detail to carry and is reported through `expandedRowKeys` alone. An unrecognized attribute
  value falls back to the controlled behaviour rather than self-managing
- `expandedRowKeys: ReadonlySet<string | number> = new Set()` (attribute: false; `ReadonlySet<K>` on a
  parameterized element) — expanded state bounded to 10,000 keys. Who writes it depends on
  `expansionMode`: under the default `'none'` the table never mutates it and consumers reassign it
  after `lr-row-expand-toggle`; under `'single'`/`'multiple'` the table writes it on each accepted
  activation, and a `preventDefault()` on `lr-row-expand-request` hands that one change back.
  Malformed and whitespace-only string keys are omitted while valid off-view keys remain controlled
  in every mode — filtering, sorting and pagination never clear them, so a row paged or filtered out
  of view comes back expanded and a key matching no current row simply renders nothing until one
  exists again (the same convention `selectedRowKeys` follows for server pagination). Reads return
  immutable detached `ReadonlySet` facades; reassign a new set to update
- `hasMore: boolean = false` (attribute `has-more`, reflected)
- `moreLabel?: string` (attribute `more-label`) — omission renders localized `loadMore` (`'Load more'` in the built-in English catalog); a supplied string, including `''`, renders verbatim
- `error: boolean = false` (attribute `error`, reflected) — replaces `<tbody>`'s row content with
  a built-in failed-load state while keeping the surrounding `<thead>`, filter field, and
  pagination mounted — unlike either data-empty branch below, which this state overrides and which
  replace that chrome too. Precedence when more than one state could apply at once: `loading` beats
  `error` beats every empty branch, so a `loading` table never shows a stale `error`, and an
  `error` table never falls through to "no rows"/"no columns" copy underneath it.
- `errorHeading?: string` (attribute `error-heading`) — omission renders localized
  `tableLoadFailed` (`'Could not load data'` in the built-in English catalog); a supplied string,
  including `''`, renders verbatim. Has no effect once the `error` slot is filled.
- `errorDescription: string = ''` (attribute `error-description`) — never localized, the same
  contract as `emptyDescription`. Has no effect once the `error` slot is filled.
- `announce: boolean = false` (reflected) — opts the table into announcing a failed-load state it
  already carries when it first mounts, through the same shared assertive region and the same
  heading text the later `error` transition announces, so the two paths cannot drift. Leave unset
  for a table that is part of the page a user is arriving on: the built-in error state renders in
  document order and repeating it is noise. Set it when the table is created in response to a user
  action — a reload that rejects mounts a fresh `error` table whose failure would otherwise never
  be spoken. Read once, on the first update: a later reconnection or adoption stages the same
  state again rather than replaying the announcement, and later `error` transitions announce
  either way. Deliberately not forwarded to the composed `[part='error']` `<lr-empty>`, whose own
  `announce` stays unset so the failure is spoken once, not twice. Remove any host
  `role="status"`/`role="alert"` hand-added before this property existed once it is set —
  otherwise the failure is announced a third time, through the native role as well.
- `emptyHeading?: string` (attribute `empty-heading`) — omission renders localized `noData` (`'No data'` in the built-in English catalog); a supplied string, including `''`, renders verbatim
- `emptyDescription: string = ''` (attribute `empty-description`)
- `noColumnsHeading?: string` (attribute `no-columns-heading`) — omission renders localized `noColumns` (`'No columns configured'` in the built-in English catalog); a supplied string,
  including `''`, renders verbatim
- `noColumnsDescription: string = ''` (attribute `no-columns-description`)
- `emptyCompact?: boolean` (attribute `empty-compact`) — overrides the built-in `[part='empty']`
  state's `compact` rendering. Tri-state: leave it `undefined` (the default) to keep each empty
  branch's own built-in default — the two shadow-root-level branches (no columns, no rows) render
  spacious, while the filtered-to-zero branch, which sits inside `[part='base']` alongside the filter
  field, renders compact. `empty-compact="false"` forces the spacious rendering everywhere, and is
  parsed as `false` rather than as mere attribute presence. Has no effect once the `empty` slot is
  filled
- `revealColumnsLabel?: string` (attribute `reveal-columns-label`) — the reveal button's label
  while `priority`-hidden columns are hidden; omission renders localized
  `showAllColumns` (`'Show all columns'` in the built-in English catalog), while a supplied string (including `''`) is verbatim
- `hideColumnsLabel?: string` (attribute `hide-columns-label`) — the same button's label once
  the columns have been revealed; omission renders localized `showFewerColumns` (`'Show fewer columns'` in the built-in English catalog), while a supplied string (including `''`) is verbatim
- `priorityColumnsVisible: boolean = false` (attribute `priority-columns-visible`, reflected) —
  forces responsive priority columns visible and is updated by the built-in reveal button.
  Priority-hidden columns hide their header, body, and footer cells together at the same measured
  overflow point in either direction; revealing columns restores all three bands.
- `storageKey?: string` (attribute `storage-key`) — when set, persists `priorityColumnsVisible` to
  `localStorage` (namespaced as `lr-table:${storageKey}`) and restores it on the next mount, without
  overwriting a `priorityColumnsVisible` the consumer declared on that same mount
  (`priority-columns-visible` present, or a `.priorityColumnsVisible=${…}` binding) — including a
  binding that pins it to `false`, its own default. Unset (the default) touches storage not at all.
  The same "explicit beats persisted" guarantee `lr-app-rail` gives each of its `persist`-selected
  fields and `lr-widget` gives `collapsed`; all three share one mechanism
- `priorityColumnsToggleAvailable: boolean` (readonly; computed, no attribute) — whether the
  reveal/hide control is currently offered at all, the public counterpart of the measurement
  `[part='reveal-columns-button']` itself renders from. True while at least one `priority` column is
  actually hidden at the current allocation, and it stays true once `priorityColumnsVisible` has
  revealed them (otherwise the control would remove itself the moment it was used). Always `false`
  with no `priority` column declared — the state the development-mode inert-configuration warning
  describes. Remeasured after every render and container resize, so read it after
  `await table.updateComplete`
- `heatTintScale?: { min?: number; max?: number }` (attribute: false) — overrides the auto-derived
  heat-tint domain (min/max of every `heatValue` result across every currently-rendered row —
  post-sort, pre-pagination, the same rows `footer(rows)` already sees). Unset (the default) computes
  the domain automatically from the data, spanning every `heatValue`-defining column together — a
  single shared scale across the whole grid, not one scale per column
- `rowTotal?: (row: T) => unknown` (attribute: false) — renders a trailing `<td>`
  (`[part='row-total-cell']`) on every body row holding this row's total. Same "consumer
  computes/renders, table only positions" contract as the existing per-column `footer(rows)` — does
  not assume addition, so a non-sum aggregate works identically. Omit for no trailing column at all
  (unchanged output)
- `grandTotal?: (rows: readonly T[]) => unknown` (attribute: false) — renders the bottom-right cell (row-total
  column × footer row). Only rendered when both `rowTotal` is set **and** at least one column defines
  `footer` — otherwise there is no footer row for it to occupy, and this renders nothing

**Methods:**

- `rowElement(rowKey)` — the rendered `<tr>` for one row key, or `null` when that row is not in the
  current render output (filtered out, paged away, never present)
- `cellElement(rowKey, columnKey)` — the rendered `<td>` at a row/column pair, on the same terms;
  `columnKey` is the column's own `key`
- `expandedContentElement(rowKey)` — the rendered `[part='expanded-cell']` holding that row's
  `expandedContent(row)` output, or `null` when the row is not currently rendered, is not expanded,
  or the table sets no `expandedContent`
- `editCell(rowKey, columnKey)` — opens the inline editor at that row/column pair, exactly as a
  double-click, `F2`, or `Enter` on the cell's own focus stop would (see `columns[].editTrigger`
  below) — the entry point for a consumer's own key binding or menu action. A no-op for an unknown
  row key, an unknown column key, or a column with no `editTrigger`. For an `editTrigger: 'always'`
  column (already open from first paint) it moves focus into that editor instead of opening
  anything.

The first three exist for code that has to reach content a `cell(row)`/`expandedContent(row)` callback
rendered into this component's shadow root — measuring it, scrolling it into view, or applying a
style `::part()` cannot express, since only pseudo-classes may follow a part selector. One method per
callback, and the split is not cosmetic: the expansion panel is a **sibling** `<tr part='expanded-row'>`
of the data row rather than a descendant of it, so `rowElement(rowKey)` cannot reach
`expandedContent` output at all — use `expandedContentElement(rowKey)` for that half. All three read
the DOM as it stands, so `await table.updateComplete` first and treat `null` as "not rendered right
now". The `data-row-key`, `data-col-key` and `data-expanded-row-key` attributes they resolve are
documented stable API: `data-col-key` is the column's own `key`, while `data-row-key` (on the data
row) and `data-expanded-row-key` (on the panel row) are a type-tagged encoding of the row key
(`string:a` vs `number:1`) that keeps a numeric key distinct from the string that stringifies the
same way. The panel deliberately does **not** repeat `data-row-key`, so a `[data-row-key]` query
still resolves exactly one element per row. Prefer these three methods over building a selector from
any of the attributes — a consumer-supplied key is not safe to interpolate into CSS unescaped.

**Events:** `lr-sort-request` (cancelable frozen readonly
`detail: { phase: 'request', sortKey, sortDir }`) precedes `lr-sort` (frozen readonly
`detail: { phase: 'commit', sortKey, sortDir }`) only when accepted. Client mode also updates its
sort properties; server mode leaves them controlled. Other events are `lr-row-click`
(`detail: { row }`), `lr-load-more` (fired on the "load more" button),
`lr-priority-columns-visibility-change` (frozen readonly `detail: { visible }`), and the expansion
pair `lr-row-expand-request` (**cancelable**, frozen readonly `detail: { row, rowKey, expanded }`,
emitted only while `expansionMode` is `'single'` or `'multiple'`; `preventDefault()` skips the
built-in `expandedRowKeys` write and suppresses the following toggle) and `lr-row-expand-toggle`
(frozen readonly `detail: { row, rowKey, expanded }`, where `expanded` is the state the activation
resolves to; under the default `expansionMode: 'none'` the table does not mutate `expandedRowKeys`,
while under a self-managed mode the write has already landed when it fires, and `'single'` fires it
once more — with `expanded: false`, immediately ahead of the accepted one — for the row it closed to
make room, unless that row is out of view), and
`lr-selection-change` (frozen readonly `detail: { rowKeys }`, not cancelable) when selection is
enabled — fired both from a row activation and from a `selectionMode` flip to `'single'` that coerces
an existing multi-row selection down to one key (skipped on the very first render, since an
already-inconsistent initial `selectionMode`/`selectedRowKeys` pairing is a starting state, not a
live transition), `lr-filter-change`
(frozen readonly `detail: { text }`), and `lr-page-change` (frozen readonly `detail: { page }`) from the
filter/pagination surfaces, and `lr-cell-edit` (`detail: { row, columnKey, value }`) for editable
columns, and `lr-column-resize` (`detail: { columnKey, width }`, `width` in CSS pixels) on every pointer or
keyboard resize step. **Only the commit is cancelable.** A pointer drag fires the event once per
pixel of movement as non-cancelable live feedback, then exactly once more — `cancelable: true` — for
the width committed at drag-end (and only when that width actually differs from the pre-drag one).
A keyboard step (Arrow/Shift+Arrow/Home/End) is already one discrete action, so it fires that single
cancelable commit directly, with no live-feedback stream. Calling `preventDefault()` on a cancelable
emission reverts the column to its pre-gesture width (or removes the override entirely if the column
had never been resized) — unless the listener also applied a width of its own during that same
synchronous dispatch, which stands rather than being rolled back over, so a listener may refuse the
proposed width and resolve the resize its own way in one step. Calling `preventDefault()` on a
mid-drag step does nothing, by design — a veto is a decision
about the final width, not about every pixel the pointer passes through.
The internal filter input's composed native `input`/`change` events are contained; only
`lr-filter-change` crosses the host boundary. Cell-editor `input`/`change` events are likewise
contained while an accepted edit publishes `lr-cell-edit`. Internal filter/cell-editor native
`focus` and `blur` are re-dispatched from the host as bubbling, composed events (the native ones
are neither). `lr-retry` — the built-in `[part='retry-button']` was activated, only rendered while
`error` is set. **Cancelable**: the default action clears `error`; calling `preventDefault()` leaves
it set, for a consumer that owns its own retry timing (e.g. it wants to keep the banner up until a
fresh load has actually started, or failed again immediately).

**Slots:** `empty` — replaces the built-in empty state on the two _data_-empty branches (no rows at
all, and filtered/paginated down to zero). Left unfilled, the built-in `[part='empty']` `<lr-empty>`
renders as this slot's fallback content. The no-columns branch is deliberately **not**
slot-replaceable: it reports a configuration problem (`noColumnsHeading`), not "this query returned
nothing", and one slot covering all three would collapse that distinction. Everything else comes
from `columns`/`rows`. `error` — replaces the built-in failed-load state, including its retry
button, while `error` is set. Left unfilled, the built-in `[part='error']` `<lr-empty>` renders as
this slot's fallback content.

**CSS parts:** `base`, `table`, `caption`, `head`, `header-cell`, `row`, `cell`, `more-button`, `sort-icon`
(each sort indicator), `sort-icon-active` (the active chevron, rotated per `sortDir`),
`sort-icon-inactive` (the muted bidirectional indicator under `sort-indicators="all"`), `reveal-columns-button`
(shown when priority columns are hidden or when a narrow allocation is currently force-visible),
`foot` (the `<tfoot>`, only rendered when at least one
column defines `footer`), `footer-row`, `footer-cell`, `row-total-cell` (each body row's trailing
`<td>` holding `rowTotal(row)`, rendered only when `rowTotal` is set — the corresponding footer-row
cell, holding `grandTotal`, is a `footer-cell` instead, matching every other footer cell),
`expand-toggle-cell`, `row-expand-toggle`,
`row-expand-icon`, `expanded-row`, `expanded-cell`, `filter-label`, `filter`, `filter-clear`
(clears the filter field, replacing the native search-cancel glyph the component resets; rendered
only while it has a value), `loading` (under
`loadingAppearance="spinner"` the visible block holding the spinner; under `"skeleton"` the
visually-hidden, `aria-hidden` announcement mirror, since the placeholder rows are the visible
affordance; the part has no live-region role in either appearance),
`skeleton` (each canonical `shape="rect"` `<lr-skeleton>` placeholder inside a skeleton-mode body cell — the placeholder rows
and cells reuse the ordinary `row`/`cell`/`row-total-cell` parts, which is exactly what keeps them
geometrically identical to real rows, so `skeleton` is the part to target for the placeholder's own
look: `::part(skeleton) { --lr-skeleton-h: 2em; }`), and
`pagination`, `cell-editor`, `group-row`, `group-cell`, and `resize-handle` (the focusable column
separator, with a finite explicit ARIA maximum even when no CSS `maxWidth` was supplied). The built-in empty state is addressable rather than fixed: `empty` is the `<lr-empty>`
host in all three empty states, and it re-exports that element's own inner parts as `empty-base`,
`empty-icon`, `empty-heading`, `empty-description` and `empty-actions`. Note that the no-columns and
no-rows states return the empty element as the shadow root's own root, with no `[part='base']`
wrapper around it — `::part(base)` does not apply in those two states, only in the filtered-to-zero
one — and that `empty` disappears entirely once the `empty` slot is filled. `error` — the built-in
`<lr-empty>` host rendered in the row body while `error` is set. Unlike `[part='empty']`'s no-rows
branches, its surrounding `<thead>`, filter, and pagination stay mounted rather than being replaced
along with it. It re-exports that element's own inner parts as `error-base`, `error-icon`,
`error-heading`, `error-description`, and `error-actions` (which wraps `retry-button`, the built-in
retry control). `error-row` — the single full-width `<tr>` replacing the row content while `error`
is set; `error-cell` — the `<td>` inside it, spanning every column, that holds the failed-load
content. Both exist only in the in-grid branch: when `columns` is empty there is no grid to keep
mounted, so the failed-load content renders standalone and neither part is present (`error` and its
re-exported inner parts still are).

- `scrollMode: 'self' | 'page' | 'auto' = 'self'` (attribute `scroll-mode`, reflected) — which element
  scrolls when the table overflows. `'self'` makes `[part="base"]` the scroll container, which is
  what pairs with `--lr-table-max-height` and makes the sticky header pin inside the table's own
  viewport. `'page'` hands scrolling back to the document. Needed because a scroll container clips
  **both** axes — CSS offers no way to scroll one and not the other — so an *uncapped* table that is
  still `overflow: auto` becomes a sticky containing block that never scrolls, and its header
  scrolls away with the page. With `'page'` the header's nearest scrollport is the page, so it pins
  there; the cost is that a table wider than its host overflows the page instead of scrolling
  inside itself. The opt-in `'auto'` mode resolves between those two behaviors from the rendered
  allocation: while content fits it uses page flow, and only while content actually overflows
  horizontally does `[part="base"]` become the contained scrollport. It re-evaluates when either
  the allocated width or the rendered table's intrinsic width changes, so the same table can flow
  with a desktop page and contain itself in a 320px panel. The default remains `'self'`. Named
  `scrollMode` rather than `scroll` because a `scroll` property would shadow `Element.prototype.scroll()`

**Themeable custom properties:** `--lr-table-cell-color` (default `inherit`),
`--lr-table-cell-link-color` (default `var(--lr-color-brand)`) and
`--lr-table-cell-link-hover-color` — an anchor returned from a column's `cell(row)` renders inside
the component's shadow root, where page CSS cannot reach it and `::part()` cannot select past the
first compound selector to reach it either, so without these it computes to the UA default link
blue; set `revert` for the UA default. `--lr-table-cell-padding` (default `var(--lr-space-s)`) sets
the padding of a header cell, a body cell, and the row-total cell; `--lr-table-cell-padding-compact`
(default `var(--lr-space-xs) var(--lr-space-s)`) is the same hook for the group-header cell and the
footer cell, which default to a tighter block/inline shorthand rather than sharing the first token
outright — two hooks instead of one preserve that distinction. `--lr-table-font-size` (default
`inherit`) sets the `<table>` element's font size; the rest of the font shorthand (family,
weight, etc.) keeps inheriting from the host regardless of this override. `--lr-table-max-height` (default `none`; controls the scrollable
body's `max-block-size`). `--lr-table-heat-tint-lo` (default `var(--lr-color-brand-quiet)`) and
`--lr-table-heat-tint-hi` (default `var(--lr-color-brand)`) — the `color-mix()` ramp endpoints
for heat-tint mode's per-cell background, consulted only on columns/rows that define `heatValue`;
`--lr-table-resize-min-width` (default `var(--lr-size-3rem)`) and
`--lr-table-resize-handle-opacity` (default `0.12`) control resizable-column behavior. The latter
remains the legacy shared opacity fallback; `--lr-table-resize-handle-hover-bg` (default
`var(--lr-color-brand)`), `--lr-table-resize-handle-hover-opacity` (defaulting to the legacy
opacity), `--lr-table-resize-handle-active-bg` (defaulting to the hover background), and
`--lr-table-resize-handle-active-opacity` (defaulting to twice the hover opacity) independently
retune the rendered interaction states. These heat-tint/resize hooks are not redeclared on the component host: set them on
`lr-table` or on a theme ancestor, and a table-level value wins through the normal cascade.
`--lr-table-row-selected-bg` (default `var(--lr-color-brand-quiet)`) — the background of a row whose
`aria-selected` is `true`, including that row's own `sticky` column cell. Like every state-scoped
custom property in this library it is an inline
`var()` fallback at its point of use and is **not** declared on `:host`, so it can be set on the
element _or on any ancestor_ and still reach the rule that reads it. It exists because Shadow Parts
forbids an attribute selector after `::part()` — `::part(row)[aria-selected='true']` is invalid CSS —
so the only prior lever for restyling the selected row was overriding the library-wide
`--lr-color-brand-quiet` token, which repaints everything else reading it.
`--lr-table-row-stripe-bg` (default `transparent`) — the background of alternating body rows,
including each row's own `sticky` column cell. The
component marks the alternating rows itself, so this works without an invalid `::part(row)` attribute
or structural-pseudo-class selector and does not affect group, expanded, hover, or selected rows. A
row's hover and pressed fills reach its `sticky` column cell the same way, through an internal
custom property rather than a themeable token, since neither state has a public hook today.
`--lr-table-header-sorted-bg` (default `var(--lr-color-surface)`) and `--lr-table-header-sorted-color` (default
`inherit`) restyle the **currently-sorted** column's header cell (`[aria-sort]` other than `none`),
including a `sticky` column's own header cell. The opaque surface default prevents body rows from
showing through the sticky header while it scrolls.
Same shape and rationale as `--lr-table-row-selected-bg`: inline `var()` fallbacks, not on `:host`,
because `::part(header-cell)[aria-sort]` is invalid CSS. The `sort-icon` part styles only the
indicator; these tokens style the header cell itself. Use `::part(sort-icon-inactive)` and
`::part(sort-icon-active)` to style the two indicator states without private selectors.
`--lr-table-sticky-offset` (default `0`) is measured and written inline per column by the component
so multiple `sticky` columns stack instead of overlapping; it is a read-out, not a knob you set.
`--lr-table-heat-t` is likewise component-written (each `[data-heat]` cell's position on the ramp).
`[part="base"]`, the table's own scroll container, 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-virtual-list`, `lr-scroller`, `lr-carousel`, `lr-code-block`, and `lr-code-editor`.

**Optional peer deps:** none.

```html
<lr-table
  id="t"
  sort-key="name"
  sort-dir="asc"
  accessible-label="Items"
></lr-table>
<script type="module">
  const t = document.getElementById("t");
  t.columns = [
    { key: "name", label: "Name", sortable: true, cell: (r) => r.name },
    // sortValue keeps the numeric column comparing as numbers, not as strings.
    {
      key: "value",
      label: "Value",
      align: "end",
      sortable: true,
      sortValue: (r) => r.value,
      cell: (r) => r.value,
    },
  ];
  t.rows = [
    { name: "Alpha", value: 1 },
    { name: "Beta", value: 2 },
  ];
  t.rowKey = (r) => r.name;
  t.addEventListener("lr-sort-request", (e) => {
    console.log("sort proposed", e.detail.sortKey, e.detail.sortDir);
    // Call e.preventDefault() here to veto the transaction.
  });
  t.addEventListener("lr-sort", (e) =>
    console.log("sort committed", e.detail.sortKey, e.detail.sortDir)
  );
  t.addEventListener("lr-row-click", (e) =>
    console.log("clicked", e.detail.row)
  );
</script>
```

**Known gotchas:**

- accepted sortable-header activation writes `sortKey`/`sortDir` only in client mode. Server mode
  keeps those properties controlled and reports the accepted proposal in `lr-sort`; veto
  `lr-sort-request` to suppress both state change and commit. The built-in transaction toggles only
  between two directions, so a tri-state header (asc → desc → unsorted) remains consumer-owned.
- `sortValue`/`cell` are read off the column object by identity. Mutating a column **in place**
  (`t.columns[0].sortValue = …`) neither re-renders nor re-sorts; assign a new `columns` array.
- `groupBy` + client sorting are **not** in conflict: the sort runs per group, not across the whole
  set, so group rows stay contiguous. The consequence is that the group order is normally yours to
  control — it follows first appearance in `rows`. The single exception is a sort on a column whose
  value is constant inside every group (the group column itself, a column functionally determined
  by the group key, or any column at all when every group holds one row): the within-group sort
  would be a provable no-op, so the groups are ordered by that constant value instead. That is what
  keeps `aria-sort` and the header chevron honest — otherwise clicking the group column would flip
  both while changing nothing. To force a group order in any other case, sort `rows` into it before
  assigning them (or set `sort-mode="server"` and own the whole ordering).
- both single and multiple row selection use `selectedRowKeys`; the component does not synthesize a
  checkbox column, so a bulk-select UI still belongs in `headerCell()`/`cell()` callbacks.
- `accessibleLabel?: string` (attribute `accessible-label`) — a typed accessible name for the
  `<table role="grid">`. Omitting it reads back `undefined`; a plain `aria-label` HTML attribute on
  the host is then forwarded instead (read via `this.getAttribute('aria-label')` at render time). An
  explicitly empty string is a real override — it renders `aria-label=""` rather than falling back to
  the host attribute. Consumer-supplied text, so neither is run through `this.localize()`.
- `caption: string = ''` — an optional visible `<caption>` (exposed as the `caption` CSS part). When
  no `accessibleLabel`/host `aria-label` is present the caption also names the grid via
  `aria-labelledby`.
- A grid with **none** of `accessibleLabel`, host `aria-label`, or `caption` logs a one-time
  `console.warn` on first render in development builds only — an unnamed grid is an accessibility
  defect that otherwise renders silently. Production and unknown/unbundled runtimes do not log it.
- `revealColumnsLabel`/`hideColumnsLabel` only ever reach the DOM on
  `[part='reveal-columns-button']`, which itself only renders while at least one column declares
  `priority`. Setting either label with no `priority` column is therefore always inert — it logs
  the same shape of one-time, development-only, production-silent `console.warn` as the missing
  accessible-name check above, rather than failing silently.
- Full roving-tabindex grid keyboard pattern (one `tabindex="0"` stop among header cells, one among
  body rows) — Left/Right/Home/End move within the header row, Up/Down/Home/End move within the
  body, Down from the header enters the body's roving stop and Up from the body's first row returns
  to the header, Enter/Space still only sort/activate — a genuine strength versus most siblings in
  this family. A `priority`-hidden header/cell is skipped when computing the visible header stops,
  so arrow-key navigation never strands the roving stop on a hidden column.
- a `cell()` template can render its own interactive content without it being swallowed by
  row/column activation. Delegated clicks and Enter/Space inspect the event's composed path for
  native buttons, links, inputs, selects, textareas, summaries, and media with controls;
  editable content; a non-negative `tabindex`; or role semantics (`button`, `checkbox`, `combobox`,
  `listbox`, `menu`, `menuitem`, `option`, `radio`, `separator`, `slider`, `spinbutton`, `switch`,
  `tab`, or `textbox`). Open-shadow custom controls expose those semantics through the composed path
  and therefore keep their own action. A passive custom element — including a formatter or
  display-only badge — remains part of the row activation surface instead of creating a dead zone
  merely because its tag contains a hyphen. An opaque closed-shadow control can explicitly opt out
  of row activation by adding `data-table-interactive` to its visible host.
- `layout="fixed"` (and any `'auto'` that resolves to fixed) carries two consequences of the CSS
  fixed algorithm. With no declared widths the **first** row — the header row included — determines
  every column's width, so revealing a `priority`-hidden column via
  `[part='reveal-columns-button']` re-measures and changes _all_ of them, not just the revealed one.
  And `columns[].minWidth`/`maxWidth` are silently ignored by `table-layout: fixed`; declare `width`
  instead when a specific column needs a specific size.
- skeleton mode keeps geometry stable only when the browser isn't sizing columns from cell content.
  Under the default `table-layout: auto`, placeholder cells have no intrinsic width, so the columns
  re-measure when real content arrives — exactly as they do between any two different data sets. For
  pixel-identical widths across the load, declare `columns[].width` or set `layout="fixed"`.
  Initial declarative loading stays silent; every later transition into either loading appearance
  appends the localized loading text to the document's shared light-DOM polite sink, including
  repeated cycles. `[part="base"]` exposes `aria-busy`, `[part="loading"]` is an `aria-hidden`
  mirror rather than a live region, and every placeholder opts out of `<lr-skeleton>`'s own
  announcement, so a skeleton table never announces once per placeholder row.
- `columns[].cellTitle` returning an empty string **or** `undefined` omits the `title` attribute
  entirely rather than rendering `title=""` — an empty `title` would suppress an ancestor element's
  own tooltip. The attribute is also suppressed while that cell is in inline-edit mode, so the
  tooltip can't shadow the editor. Accessibility caveat: some screen readers announce a `<td title>`
  as the cell's accessible _name_, replacing the cell's content rather than supplementing it (the
  same caveat `lr-stat`'s `exactValue` carries). Use it for a longer form of what the cell already
  shows, never for information that exists nowhere else.
- `editTrigger: 'always'` deliberately does not re-assert a `'text'`/`'number'` cell's source value
  once the user has typed into it. That is the native dirty-value-flag behavior the attribute
  binding buys, and it is the point: a background `rows` refresh cannot silently overwrite an
  in-progress edit. If you need the
  opposite — an authoritative external value that always wins — do not use `'always'`; re-key the
  row (`rowKey`) so the editor is recreated rather than updated, or use `editTrigger: 'double-click'` and let
  the short-lived double-click editor's property binding re-assert. Also note the two things
  `'always'` intentionally does _not_ do: it never sets the roving `tabindex` (its editors are
  ordinary tab stops, so Tab order in that column interleaves with the grid's two roving stops,
  the same way the row-expand toggle's already does), and it never cancels Escape.
- `editType: 'select'` renders a native `<select>`, populated from `columns[].editOptions`
  (`{ value: string; label: string }[]`, one `<option>` per entry in order); a `'select'` column
  with no `editOptions` renders an empty, valueless `<select>` rather than throwing. Unlike the
  `'text'`/`'number'` editors, `<select>`/`<option>` carry no native dirty-value flag, so the
  protection described above for a persistent `'always'` editor does not extend to a `'select'`
  one — a background `rows` refresh re-applies the selection to a persistent select editor even
  after the user has picked a different, uncommitted option. The double-click flavor is unaffected
  by this distinction either way, since it is always short-lived.

---
