Skip to content

cv-grid

Interactive data grid component with keyboard navigation, cell selection, and accessible tabular data display following the WAI-ARIA Grid pattern.

Headless: createGrid

Cross-Spec Consistency

This document is the UIKit surface contract for Grid.

  • Headless createGrid is the source of truth for state, transitions, and invariants.
  • UIKit mirrors headless contracts through DOM attributes and events.
  • Any intentional divergence between UIKit and headless MUST be documented in both specs.

Usage

View source
html
<div class="grid-demo-shell" data-demo="grid" data-live-demo-height="720">
  <section class="grid-demo-hero" aria-labelledby="grid-demo-title">
    <p class="grid-demo-kicker">APG grid adapter</p>
    <h2 id="grid-demo-title">Vault authorization matrix</h2>
    <p>
      Cell focus, multi-selection, disabled states, and event detail all come from the same headless grid
      contract.
    </p>
  </section>

  <div class="grid-demo-toolbar" aria-label="Grid contract summary">
    <span>role="grid"</span>
    <span>selection-mode="multiple"</span>
    <span>roving tabindex</span>
    <span>PageUp/PageDown</span>
  </div>

  <div class="grid-demo-table-scroll">
    <cv-grid
      id="grid-demo-matrix"
      aria-label="Vault authorization matrix"
      selection-mode="multiple"
      page-size="2"
      value="vault::owner"
      total-row-count="8"
      total-column-count="4"
    >
      <cv-grid-column value="scope">Boundary</cv-grid-column>
      <cv-grid-column value="owner">Owner</cv-grid-column>
      <cv-grid-column value="state">State</cv-grid-column>
      <cv-grid-column value="risk">Risk</cv-grid-column>

      <cv-grid-row value="vault">
        <cv-grid-cell column="scope">Primary vault</cv-grid-cell>
        <cv-grid-cell column="owner">Alex</cv-grid-cell>
        <cv-grid-cell column="state">Verified</cv-grid-cell>
        <cv-grid-cell column="risk">Low</cv-grid-cell>
      </cv-grid-row>
      <cv-grid-row value="relay">
        <cv-grid-cell column="scope">USB relay</cv-grid-cell>
        <cv-grid-cell column="owner">Device core</cv-grid-cell>
        <cv-grid-cell column="state">Paired</cv-grid-cell>
        <cv-grid-cell column="risk">Medium</cv-grid-cell>
      </cv-grid-row>
      <cv-grid-row value="escrow">
        <cv-grid-cell column="scope">Emergency share</cv-grid-cell>
        <cv-grid-cell column="owner">Counsel</cv-grid-cell>
        <cv-grid-cell column="state">Pending</cv-grid-cell>
        <cv-grid-cell column="risk">Review</cv-grid-cell>
      </cv-grid-row>
      <cv-grid-row value="decoy">
        <cv-grid-cell column="scope">Decoy namespace</cv-grid-cell>
        <cv-grid-cell column="owner" disabled>No access</cv-grid-cell>
        <cv-grid-cell column="state">Isolated</cv-grid-cell>
        <cv-grid-cell column="risk">Hidden</cv-grid-cell>
      </cv-grid-row>
    </cv-grid>
  </div>

  <output class="grid-demo-readout" for="grid-demo-matrix" aria-live="polite">
    Active cell: vault::owner · Selected cells: 3
  </output>
</div>

<script>
  customElements.whenDefined('cv-grid').then(async () => {
    const grid = document.querySelector('#grid-demo-matrix')
    const readout = document.querySelector('.grid-demo-readout')
    if (!grid || !readout) return

    const syncReadout = () => {
      const selectedCount = grid.selectedValues?.length ?? 0
      readout.textContent = `Active cell: ${grid.value || 'none'} · Selected cells: ${selectedCount}`
    }

    grid.addEventListener('cv-input', syncReadout)
    grid.addEventListener('cv-change', syncReadout)
    await grid.updateComplete
    grid.selectedValues = ['vault::owner', 'relay::scope', 'escrow::risk']
    await grid.updateComplete
    syncReadout()
  })
</script>

Anatomy

<cv-grid> (host)
└── <div part="base" role="grid">
    ├── <div role="rowgroup" part="head">
    │   └── <div role="row" part="head-row">
    │       └── <slot name="columns">       ← cv-grid-column elements
    ├── <div role="rowgroup" part="body">
    │   └── <slot name="rows">              ← cv-grid-row elements

Attributes

AttributeTypeDefaultDescription
valueString""Active cell key in "rowId::colId" format. Reflects the currently focused cell.
selection-modeString"single"Selection mode: "single" | "multiple"
focus-strategyString"roving-tabindex"Focus management strategy: "roving-tabindex" | "aria-activedescendant"
selection-follows-focusBooleanfalseAuto-select cell on focus move
page-sizeNumber10Rows per page for PageUp/PageDown navigation (minimum 1)
readonlyBooleanfalseMarks all cells as aria-readonly
aria-labelString""Accessible label for the grid root. Falls back to "Grid" when no aria-labelledby is set.
aria-labelledbyString""aria-labelledby reference for the grid root
total-row-countNumber0Logical row count for virtualization. When > 0, overrides aria-rowcount.
total-column-countNumber0Logical column count for virtualization. When > 0, overrides aria-colcount.

JS-only property:

PropertyTypeDefaultDescription
selectedValuesstring[][]Array of selected cell keys in "rowId::colId" format

Slots

SlotDescription
columnsOne or more <cv-grid-column> elements defining the column headers
rowsOne or more <cv-grid-row> elements containing <cv-grid-cell> children

CSS Parts

PartElementDescription
base<div>Root grid container with role="grid", table layout
head<div>Column header row group (role="rowgroup")
head-row<div>Row wrapping the column header slots (role="row")
body<div>Data row group (role="rowgroup")

CSS Custom Properties

The grid component does not expose dedicated --cv-grid-* custom properties on the host. Styling is controlled through theme tokens consumed as fallback values:

Theme PropertyDefaultDescription
--cv-color-border#2a3245Border color for the grid outline
--cv-color-surface#141923Background color of the grid base
--cv-radius-md10pxBorder radius of the grid base

Visual States

Host selectorDescription
:hostBlock display
:host([selection-mode="multiple"])Multiple cell selection enabled; grid root reflects aria-multiselectable="true"
:host([focus-strategy="aria-activedescendant"])Grid root gets tabindex="0" and aria-activedescendant; cells all get tabindex="-1"
:host([readonly])All cells reflect aria-readonly="true"

ARIA Contract

  • Grid root role is grid
  • Row group elements use role="rowgroup"
  • Head row uses role="row"
  • Column headers use role="columnheader" (set by UIKit on cv-grid-column)
  • Data rows use role="row" (from headless getRowProps)
  • Data cells use role="gridcell" (from headless getCellProps)
  • Focus strategies:
    • roving-tabindex (default) -- active cell gets tabindex="0", all others tabindex="-1", grid root gets tabindex="-1"
    • aria-activedescendant -- grid root gets tabindex="0" and aria-activedescendant pointing to active cell DOM id; all cells get tabindex="-1"
  • Required attributes on root: aria-label or aria-labelledby, aria-multiselectable, aria-colcount, aria-rowcount
  • Required attributes on rows: aria-rowindex
  • Required attributes on cells: aria-colindex, aria-selected, tabindex, data-active
  • Conditional cell attributes: aria-readonly (when readonly), aria-disabled (when cell is disabled)

All ARIA attributes are derived from headless contracts (getGridProps, getRowProps, getCellProps). UIKit does not compute ARIA state independently.

Events

EventDetailDescription
cv-input{value: string | null, activeCell: GridCellId | null, selectedValues: string[]}Fires when active cell or selection changes due to interaction
cv-change{value: string | null, activeCell: GridCellId | null, selectedValues: string[]}Fires when active cell or selection changes due to interaction

Event detail shape:

FieldTypeDescription
valuestring | nullActive cell key ("rowId::colId") or null
activeCellGridCellId | nullActive cell object {rowId, colId} or null
selectedValuesstring[]All selected cell keys in "rowId::colId" format

Both cv-input and cv-change fire together whenever active cell or selection state changes as a result of keyboard navigation, click interaction, or programmatic value/selectedValues updates that alter model state.

Reactive State Mapping

cv-grid is a visual adapter over headless createGrid.

Attribute to Headless (UIKit -> Headless)

UIKit PropertyDirectionHeadless Binding
valueattr -> actionParsed to GridCellId, calls actions.setActiveCell(cell) if valid and different from current
selectedValuesprop -> actionParsed to cell ids, calls actions.selectCell() (single) or actions.toggleCellSelection() (multiple) after clearing
selection-modeattr -> optionPassed as selectionMode in createGrid(options) -- triggers model rebuild
focus-strategyattr -> optionPassed as focusStrategy in createGrid(options) -- triggers model rebuild
selection-follows-focusattr -> optionPassed as selectionFollowsFocus in createGrid(options) -- triggers model rebuild
page-sizeattr -> optionPassed as pageSize in createGrid(options) -- triggers model rebuild
readonlyattr -> optionPassed as isReadOnly in createGrid(options) -- triggers model rebuild
aria-labelattr -> optionPassed as ariaLabel in createGrid(options) -- triggers model rebuild
aria-labelledbyattr -> optionPassed as ariaLabelledBy in createGrid(options) -- triggers model rebuild
total-row-countattr -> optionPassed as totalRowCount in createGrid(options) -- triggers model rebuild
total-column-countattr -> optionPassed as totalColumnCount in createGrid(options) -- triggers model rebuild

Headless to DOM (Headless -> UIKit)

Headless StateDirectionDOM Reflection
state.activeCellId()state -> attr[value] host attribute as "rowId::colId"
state.selectedCellIds()state -> propselectedValues JS property as string[]
contracts.getGridProps()state -> renderSpread onto [part="base"]: role, tabindex, aria-label, aria-labelledby, aria-multiselectable, aria-colcount, aria-rowcount, aria-activedescendant
contracts.getRowProps(rowId)state -> renderSpread onto cv-grid-row elements: id, role, aria-rowindex
contracts.getCellProps(rowId, colId)state -> renderSpread onto cv-grid-cell elements: id, role, tabindex, aria-colindex, aria-selected, aria-readonly, aria-disabled, data-active

UIKit-Only Concerns (NOT in headless)

  • Column header ARIA attributes (role="columnheader", aria-colindex, aria-disabled) -- applied by UIKit directly to cv-grid-column elements
  • Slot-based model rebuilding: UIKit scans cv-grid-column, cv-grid-row, and cv-grid-cell from the light DOM and rebuilds the headless model on slot changes and direct child mutations (via MutationObserver on the host childList)
  • DOM focus management: when activeCellId changes in roving-tabindex mode, UIKit calls .focus() on the corresponding cv-grid-cell
  • Click handling with modifier keys: Ctrl/Meta+click toggles selection in multiple mode; plain click selects
  • cv-grid-row-slotchange internal event: cv-grid-row dispatches this when its cells change, triggering model rebuild
  • Auto-generated fallback values for column (column-{n}) and row (row-{n}) identifiers when value is empty
  • Cell validity filtering: cells referencing non-existent columns are hidden
  • preventDefault() on grid-handled keyboard events (arrows, Home, End, PageUp, PageDown, Enter, Space)

Behavioral Contract

Model Rebuild

The headless model is rebuilt when:

  • The component connects to the DOM
  • Any option attribute changes (selection-mode, focus-strategy, selection-follows-focus, page-size, readonly, aria-label, aria-labelledby, total-row-count, total-column-count)
  • Slot content changes (columns added/removed, rows added/removed, cells within rows changed)

During rebuild with preserveState=true, the current active cell and selected cells are restored if they remain valid in the new model.

Click Interaction

  • Plain click on a cell: sets active cell and selects it
  • Ctrl/Meta+click on a cell (multiple mode): sets active cell and toggles selection for that cell
  • Disabled cells ignore click interaction
  • After click, DOM focus moves to the active cell (roving-tabindex mode)

Keyboard Navigation

All keyboard interaction is delegated to headless actions.handleKeyDown(event). UIKit calls preventDefault() on handled keys and manages DOM focus after state changes.

KeyModifierAction
ArrowUp--Move active cell up
ArrowDown--Move active cell down
ArrowLeft--Move active cell left
ArrowRight--Move active cell right
Home--Move to first cell in current row
End--Move to last cell in current row
HomeCtrl / MetaMove to first cell in grid
EndCtrl / MetaMove to last cell in grid
PageUp--Move up by page-size rows
PageDown--Move down by page-size rows
Enter--Move active cell down
Space--Select active cell (single mode) or toggle selection (multiple mode)

Out of Scope

The following features are explicitly out of scope for the current implementation:

  • Cell editing -- inline input mode for cell content
  • Column reordering -- drag-and-drop or programmatic column order changes
  • Column resizing -- adjustable column widths via drag handles
  • Context menus -- right-click or long-press context menu integration

Child Elements

cv-grid-column

Column header definition. The parent cv-grid assigns role="columnheader", aria-colindex, and aria-disabled attributes.

Anatomy

<cv-grid-column> (host)
└── <slot>{label fallback}</slot>

Attributes

AttributeTypeDefaultDescription
valueString""Unique column identifier. Auto-generated as column-{n} if empty.
labelString""Fallback label text rendered when no slotted content is provided
indexNumber0Explicit 1-based aria-colindex. When < 1, positional index is used.
disabledBooleanfalseWhether the column is disabled (all cells in this column become disabled)

Slots

SlotDescription
(default)Column header content. Falls back to label attribute text.

CSS Custom Properties (inherited from theme)

Theme PropertyDefaultDescription
--cv-space-28pxVertical padding
--cv-space-312pxHorizontal padding
--cv-color-border#2a3245Bottom border color
--cv-color-text#e8ecf6Text color
--cv-color-surface#141923Background color (mixed at 82% opacity)

Visual States

Host selectorDescription
:hostTable-cell display, font-weight: 600, tinted surface background
:host([disabled])Reduced opacity (0.55)

cv-grid-row

Data row container. The parent cv-grid assigns id, role="row", and aria-rowindex from headless getRowProps.

Anatomy

<cv-grid-row> (host)
└── <slot>       ← accepts cv-grid-cell children

Attributes

AttributeTypeDefaultDescription
valueString""Unique row identifier. Auto-generated as row-{n} if empty.
indexNumber0Explicit 1-based aria-rowindex. When < 1, positional index is used.
disabledBooleanfalseWhether the row is disabled (all cells in this row become disabled)

Slots

SlotDescription
(default)One or more <cv-grid-cell> children

Internal Events

EventBubblesDescription
cv-grid-row-slotchangeYes (composed)Dispatched when slotted cell children change. The parent cv-grid listens for this to trigger model rebuild.

Visual States

Host selectorDescription
:hostTable-row display
:host([disabled])Reduced opacity (0.55)

cv-grid-cell

Individual data cell within a grid row. The parent cv-grid manages all ARIA attributes on this element via headless contracts and direct event wiring.

Anatomy

<cv-grid-cell> (host)
└── <slot>

Attributes

AttributeTypeDefaultDescription
columnString""Column identifier this cell belongs to. Auto-assigned from positional column if empty.
disabledBooleanfalseWhether the cell is individually disabled
activeBooleanfalseWhether the cell is the active (focused) cell. Managed by parent.
selectedBooleanfalseWhether the cell is selected. Managed by parent.

Slots

SlotDescription
(default)Cell content

CSS Custom Properties (inherited from theme)

Theme PropertyDefaultDescription
--cv-space-28pxVertical padding
--cv-space-312pxHorizontal padding
--cv-color-border#2a3245Bottom border color (at 70% mix)
--cv-color-text#e8ecf6Text color
--cv-color-primary#65d7ffActive/selected background tint and focus outline color

Visual States

Host selectorDescription
:hostTable-cell display, padding, bottom border, no outline
:host([active])Primary-tinted background at 14% opacity
:host([selected])Primary-tinted background at 24% opacity
:host([disabled])Reduced opacity (0.55)
:host(:focus-visible)2px solid primary outline with -2px offset
:host([hidden])Hidden (cell references non-existent column)

ChromVoid UIKit documentation