Skip to content

cv-treegrid

Hierarchical tabular data grid combining multi-column structure with tree expansion/collapse behavior, providing APG-aligned keyboard navigation and row selection.

Headless: createTreegrid

Usage

View source
html
<div class="treegrid-demo-shell" data-demo="treegrid" data-live-demo-height="760">
  <section class="treegrid-demo-hero" aria-labelledby="treegrid-demo-title">
    <div class="treegrid-demo-copy">
      <span class="treegrid-demo-kicker">Hierarchical grid adapter</span>
      <h3 id="treegrid-demo-title">Map nested vault layers without losing column context.</h3>
      <p>
        Treegrid keeps row hierarchy, expansion state, row selection, active-cell focus, and column semantics
        on one APG-aligned contract.
      </p>
    </div>

    <dl class="treegrid-demo-metrics" aria-label="Treegrid behavior summary">
      <div>
        <dt>Root</dt>
        <dd>treegrid</dd>
      </div>
      <div>
        <dt>Rows</dt>
        <dd>nested branches</dd>
      </div>
      <div>
        <dt>State</dt>
        <dd>focus + select + expand</dd>
      </div>
    </dl>
  </section>

  <section class="treegrid-demo-workbench" aria-labelledby="treegrid-demo-workbench-title">
    <div class="treegrid-demo-section-header">
      <span class="treegrid-demo-kicker">Vault trust map</span>
      <h4 id="treegrid-demo-workbench-title">Branch rows reveal visible and hidden operating layers</h4>
    </div>

    <div class="treegrid-demo-toolbar" aria-label="Active treegrid capabilities">
      <span>selection-mode="multiple"</span>
      <span>rowheader cells</span>
      <span>expanded branches</span>
      <span>roving tabindex</span>
    </div>

    <div class="treegrid-demo-scroll">
      <cv-treegrid
        id="treegrid-demo-map"
        aria-label="Vault trust boundary map"
        selection-mode="multiple"
        value="visible-surface::status"
      >
        <cv-treegrid-column value="layer" cell-role="rowheader">Layer</cv-treegrid-column>
        <cv-treegrid-column value="status">Status</cv-treegrid-column>
        <cv-treegrid-column value="owner">Owner</cv-treegrid-column>
        <cv-treegrid-column value="scope">Scope</cv-treegrid-column>

        <cv-treegrid-row value="visible-surface">
          <cv-treegrid-cell column="layer">Visible surface</cv-treegrid-cell>
          <cv-treegrid-cell column="status"
            ><cv-badge variant="primary" size="small">Visible</cv-badge></cv-treegrid-cell
          >
          <cv-treegrid-cell column="owner">Traveler</cv-treegrid-cell>
          <cv-treegrid-cell column="scope">Inspect</cv-treegrid-cell>
          <cv-treegrid-row value="travel-profile" slot="children">
            <cv-treegrid-cell column="layer">Travel profile</cv-treegrid-cell>
            <cv-treegrid-cell column="status"
              ><cv-badge variant="success" size="small">Ready</cv-badge></cv-treegrid-cell
            >
            <cv-treegrid-cell column="owner">Alex</cv-treegrid-cell>
            <cv-treegrid-cell column="scope">Allowed</cv-treegrid-cell>
          </cv-treegrid-row>
          <cv-treegrid-row value="border-docs" slot="children">
            <cv-treegrid-cell column="layer">Border docs</cv-treegrid-cell>
            <cv-treegrid-cell column="status"
              ><cv-badge variant="neutral" size="small">Decoy</cv-badge></cv-treegrid-cell
            >
            <cv-treegrid-cell column="owner">Maria</cv-treegrid-cell>
            <cv-treegrid-cell column="scope">Visible</cv-treegrid-cell>
          </cv-treegrid-row>
        </cv-treegrid-row>

        <cv-treegrid-row value="sealed-core">
          <cv-treegrid-cell column="layer">Sealed core</cv-treegrid-cell>
          <cv-treegrid-cell column="status"
            ><cv-badge variant="success" size="small">Verified</cv-badge></cv-treegrid-cell
          >
          <cv-treegrid-cell column="owner">Device</cv-treegrid-cell>
          <cv-treegrid-cell column="scope">Device</cv-treegrid-cell>
          <cv-treegrid-row value="primary-vault" slot="children">
            <cv-treegrid-cell column="layer">Primary vault</cv-treegrid-cell>
            <cv-treegrid-cell column="status"
              ><cv-badge variant="success" size="small">Sealed</cv-badge></cv-treegrid-cell
            >
            <cv-treegrid-cell column="owner">Alex</cv-treegrid-cell>
            <cv-treegrid-cell column="scope">Hidden</cv-treegrid-cell>
            <cv-treegrid-row value="otp-seeds" slot="children">
              <cv-treegrid-cell column="layer">OTP seed group</cv-treegrid-cell>
              <cv-treegrid-cell column="status"
                ><cv-badge variant="warning" size="small">Rotating</cv-badge></cv-treegrid-cell
              >
              <cv-treegrid-cell column="owner">Vault</cv-treegrid-cell>
              <cv-treegrid-cell column="scope">Session</cv-treegrid-cell>
            </cv-treegrid-row>
          </cv-treegrid-row>
          <cv-treegrid-row value="relay-core" slot="children">
            <cv-treegrid-cell column="layer">Relay core</cv-treegrid-cell>
            <cv-treegrid-cell column="status"
              ><cv-badge variant="success" size="small">Paired</cv-badge></cv-treegrid-cell
            >
            <cv-treegrid-cell column="owner">USB device</cv-treegrid-cell>
            <cv-treegrid-cell column="scope">Local</cv-treegrid-cell>
          </cv-treegrid-row>
        </cv-treegrid-row>

        <cv-treegrid-row value="recovery-envelope">
          <cv-treegrid-cell column="layer">Recovery envelope</cv-treegrid-cell>
          <cv-treegrid-cell column="status"
            ><cv-badge variant="warning" size="small">Review</cv-badge></cv-treegrid-cell
          >
          <cv-treegrid-cell column="owner">Counsel</cv-treegrid-cell>
          <cv-treegrid-cell column="scope">Shared</cv-treegrid-cell>
          <cv-treegrid-row value="expired-export" slot="children" disabled>
            <cv-treegrid-cell column="layer" disabled>Expired export</cv-treegrid-cell>
            <cv-treegrid-cell column="status"
              ><cv-badge variant="danger" size="small">Blocked</cv-badge></cv-treegrid-cell
            >
            <cv-treegrid-cell column="owner">Legacy</cv-treegrid-cell>
            <cv-treegrid-cell column="scope">None</cv-treegrid-cell>
          </cv-treegrid-row>
        </cv-treegrid-row>
      </cv-treegrid>
    </div>

    <output class="treegrid-demo-readout" for="treegrid-demo-map" aria-live="polite">
      Active cell: visible-surface::status | Selected rows: none | Expanded branches: none
    </output>
  </section>
</div>

<script>
  document
    .querySelectorAll('.treegrid-demo-shell[data-demo="treegrid"]:not([data-ready])')
    .forEach((shell) => {
      shell.dataset.ready = 'true'

      customElements.whenDefined('cv-treegrid').then(async () => {
        const treegrid = shell.querySelector('#treegrid-demo-map')
        const readout = shell.querySelector('.treegrid-demo-readout')
        if (!treegrid || !readout) return

        const rowLabels = new Map([
          ['visible-surface', 'Visible surface'],
          ['travel-profile', 'Travel profile'],
          ['border-docs', 'Border docs'],
          ['sealed-core', 'Sealed core'],
          ['primary-vault', 'Primary vault'],
          ['otp-seeds', 'OTP seed group'],
          ['relay-core', 'Relay core'],
          ['recovery-envelope', 'Recovery envelope'],
          ['expired-export', 'Expired export'],
        ])

        const formatRows = (values, fallback) => {
          const labels = values.map((value) => rowLabels.get(value) || value)
          return labels.length > 0 ? labels.join(', ') : fallback
        }

        const syncReadout = () => {
          readout.textContent = [
            `Active cell: ${treegrid.value || 'none'}`,
            `Selected rows: ${formatRows(treegrid.selectedValues || [], 'none')}`,
            `Expanded branches: ${formatRows(treegrid.expandedValues || [], 'none')}`,
          ].join(' | ')
        }

        treegrid.addEventListener('cv-input', syncReadout)
        treegrid.addEventListener('cv-change', syncReadout)
        await treegrid.updateComplete
        treegrid.expandedValues = ['visible-surface', 'sealed-core', 'primary-vault', 'recovery-envelope']
        treegrid.selectedValues = ['visible-surface', 'sealed-core']
        treegrid.value = 'visible-surface::status'
        await treegrid.updateComplete
        syncReadout()
      })
    })
</script>

Anatomy

<cv-treegrid> (host)
└── <div part="base" role="treegrid">
    ├── <div part="header" role="row">
    │   └── <span part="columnheader">   ← first rowheader/first column also exposes tree-column-header
    └── <slot>                           ← accepts cv-treegrid-column and cv-treegrid-row children

Attributes

AttributeTypeDefaultDescription
valueString""Active cell identifier encoded as "rowId::colId"; reflects current activeCellId from headless state
selected-values[]Property-only (not an HTML attribute). Array of selected row id strings; reflects selectedRowIds from headless state
expanded-values[]Property-only (not an HTML attribute). Array of expanded row id strings; reflects expandedRowIds from headless state
selection-modeString"single"Row selection mode: single | multiple
aria-labelString""Accessible label applied to the root [role=treegrid] element
aria-labelledbyString""Id reference applied as aria-labelledby on the root [role=treegrid] element

Slots

SlotDescription
(default)Accepts cv-treegrid-column definition elements followed by cv-treegrid-row elements; slot changes trigger model rebuild

CSS Parts

PartElementDescription
base<div>Root interactive element with role="treegrid"; receives all ARIA grid attributes and keyboard event handling
header<div>Rendered column header row
columnheader<span>Column header cell
tree-column-header<span>Inner label wrapper inside the tree column header

CSS Custom Properties

PropertyDefaultDescription
--cv-treegrid-column-countcomputedNumber of columns; auto-written by syncElementsFromModel() onto each row element as an inline style property

Additionally, component styles depend on theme tokens through fallback values:

Theme PropertyDefaultDescription
--cv-color-border#2a3245Border color for the base wrapper
--cv-color-surface#141923Background color for the base wrapper
--cv-color-primary#65d7ffFocus outline color for the base wrapper
--cv-radius-md10pxBorder radius for the base wrapper

Visual States

Host selectorDescription
:hostdisplay: block; contains scrollable overflow
[part="base"]:focus-visibleoutline: 2px solid var(--cv-color-primary) at outline-offset: 1px

Events

EventDetailDescription
cv-input{ value: string | null, activeCell: TreegridCellId | null, selectedValues: string[], expandedValues: string[] }Fires on any user interaction that changes active cell, selection, or expansion state
cv-change{ value: string | null, activeCell: TreegridCellId | null, selectedValues: string[], expandedValues: string[] }Fires when selection or expansion state commits (subset of cv-input cases; active-cell-only changes do not fire cv-change)
cv-treegrid-row-toggle{ rowId: string }Fires from the tree-control cell disclosure button before cv-treegrid handles it with actions.toggleRowExpanded(rowId)

value in the detail is null when no cell is active, otherwise the "rowId::colId" string.

cv-input and cv-change only fire for user-driven interactions (keyboard, pointer). Programmatic changes via selectedValues, expandedValues, or value properties do not re-dispatch these events.

Reactive State Mapping

cv-treegrid is a visual adapter over headless createTreegrid.

UIKit properties → headless actions

UIKit PropertyDirectionHeadless Binding
value (attr)attr → actioncontracts.getCellProps(rowId, colId).onFocus() (sets active cell)
selectedValues (prop)prop → actionactions.selectRow(id) (single mode) or actions.toggleRowSelection(id) (multiple mode, diff-applied)
expandedValues (prop)prop → actionactions.expandRow(id) / actions.collapseRow(id) (diff-applied)
selectionMode (attr)attr → optionpassed as selectionMode to createTreegrid(options) on model rebuild
aria-label (attr)attr → optionpassed as ariaLabel to createTreegrid(options) on model rebuild
aria-labelledby (attr)attr → optionpassed as ariaLabelledBy to createTreegrid(options) on model rebuild

Headless state → DOM attributes

Headless SignalDirectionDOM Reflection
state.activeCellId()state → attrvalue property ("rowId::colId" string); tabindex="0" and data-active="true" on active cell via getCellProps
state.selectedRowIds()state → attrselectedValues property; aria-selected="true" on selected rows and cells via getRowProps/getCellProps; [selected] on row and cell elements
state.expandedRowIds()state → attrexpandedValues property; aria-expanded="true/false" on branch rows via getRowProps; child row visibility toggled via hidden; tree-control cell receives [branch] and [expanded]
state.rowCount()state → attraria-rowcount on [part="base"] via getTreegridProps
state.columnCount()state → attraria-colcount on [part="base"] via getTreegridProps; --cv-treegrid-column-count inline style on each row

Contracts spread onto DOM elements

ContractSpread target
contracts.getTreegridProps()[part="base"] (role, tabindex, aria-label, aria-labelledby, aria-rowcount, aria-colcount, aria-multiselectable)
contracts.getRowProps(rowId)Each cv-treegrid-row element (id, role, aria-level, aria-posinset, aria-setsize, aria-rowindex, aria-expanded, aria-selected, aria-disabled)
contracts.getCellProps(rowId, colId)Each cv-treegrid-cell element (id, role, tabindex, aria-colindex, aria-selected, aria-disabled, data-active); onFocus wired to cell focus event

cv-treegrid also marks exactly one visible valid cell per row as [tree-control]: the first rowheader column cell, falling back to the first valid cell. That cell receives parent-written branch, expanded, level, and property-only rowId values for the disclosure affordance.

Pointer and keyboard action triggers

User TriggerAction Called
click on a branch disclosure buttonCalls actions.toggleRowExpanded(rowId); does not update active cell or row selection
click on a cellSets active cell via onFocus(); then calls actions.toggleRowSelection(rowId) in multiple mode (plain or Ctrl/Meta click both accumulate), or actions.selectRow(rowId) (replace) in single mode
keydown Enter or Space on active cellactions.selectRow(activeRowId) (non-additive) or actions.toggleRowSelection(activeRowId) (when Ctrl/Meta held in multiple mode)
keydown navigation keysactions.handleKeyDown(event)
focus on a cellcontracts.getCellProps(rowId, colId).onFocus()
slot content changeModel rebuilt from DOM (rebuildModelFromSlot(preserveState: true))
selection-mode / aria-label / aria-labelledby changeModel rebuilt from DOM (rebuildModelFromSlot(preserveState: true))

Keyboard Interaction

Derived from headless handleKeyDown contract:

KeyBehavior
ArrowUpMove active cell to same column in previous visible enabled row
ArrowDownMove active cell to same column in next visible enabled row
ArrowLeftIf focused row is an expanded branch: collapse it. If focused row has a parent: move to same column in parent row. Otherwise: move to previous enabled cell in current row
ArrowRightIf focused row is a collapsed branch: expand it (focus stays). If focused row is an expanded branch: move to same column in first child row. Otherwise (leaf): move to next enabled cell in current row
HomeMove to first enabled cell in current row
EndMove to last enabled cell in current row
Ctrl+Home / Meta+HomeMove to first enabled cell in first visible enabled row
Ctrl+End / Meta+EndMove to last enabled cell in last visible enabled row
Enter / SpaceSelect active row (toggles in multiple mode when Ctrl/Meta held)

Keys ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, Enter, and Space are always preventDefault()-ed when handled.

ARIA

ElementRoleRequired Attributes
[part="base"]treegridaria-label or aria-labelledby, aria-multiselectable, aria-rowcount, aria-colcount
cv-treegrid-rowrowaria-level, aria-posinset, aria-setsize, aria-rowindex, aria-selected; aria-expanded (branch rows only); aria-disabled (disabled rows only)
cv-treegrid-cellgridcell | rowheader | columnheaderaria-colindex, aria-selected, tabindex; aria-disabled (disabled cells only)

aria-level starts at 1 for root rows. aria-multiselectable is "true" when selection-mode="multiple", "false" otherwise.

Child Elements

cv-treegrid-row

Represents a single data row. Slotted directly into cv-treegrid (root rows) or into slot="children" of a parent cv-treegrid-row (child rows).

Anatomy

<cv-treegrid-row> (host)
├── <div part="row">
│   └── <slot>                   ← accepts cv-treegrid-cell children
└── <div part="children" hidden?=${!expanded}>
    └── <slot name="children">   ← accepts nested cv-treegrid-row children

Attributes

AttributeTypeDefaultDescription
valueString""Row identifier. Auto-assigned as "row-N" if empty
indexNumber0Explicit aria-rowindex override; values < 1 or non-finite are ignored (headless assigns positional index)
disabledBooleanfalseMarks row as disabled; excluded from navigation and selection
activeBooleanfalseSet by parent when a cell in this row is the active cell; drives row-level highlight
selectedBooleanfalseSet by parent when this row is selected; drives row-level selection styling
expandedBooleanfalseSet by parent; controls visibility of [part="children"] and reflects aria-expanded
branchBooleanfalseSet by parent when this row has child rows; mirrored onto the row's tree-control cell for the disclosure affordance
levelNumber1Nesting depth. Auto-written by parent cv-treegrid.syncElementsFromModel() from getRowProps()['aria-level']; root rows get 1, child rows get 2, grandchild rows get 3, etc.

Slots

SlotDescription
(default)Accepts cv-treegrid-cell elements for the row's columns
childrenAccepts nested cv-treegrid-row elements; shown only when [expanded] is set

CSS Parts

PartElementDescription
row<div>Grid row layout element; uses CSS grid with --cv-treegrid-column-count columns
children<div>Container for nested child rows; hidden when [expanded] is absent

CSS Custom Properties

PropertyDefaultDescription
--cv-treegrid-child-indent14pxCompatibility alias inherited by tree-control cells as the default --cv-treegrid-indent-size
--cv-treegrid-level1Current nesting depth (written by the row's own render from this.level)
--cv-treegrid-column-count1Number of columns; written by parent cv-treegrid as an inline style on each row; drives the grid-template-columns

Additionally, component styles depend on theme tokens:

Theme PropertyDefaultDescription
--cv-color-primary#65d7ffActive/selected row background tint and focus outline color
--cv-space-28pxInline padding for [part="row"]

Visual States

Host selectorDescription
:host([hidden])display: none
:host(:focus-visible) [part="row"]outline: 2px solid var(--cv-color-primary) at outline-offset: -2px
:host([active]) [part="row"]Primary-tinted background (var(--cv-color-active))
:host([selected]) [part="row"]Same primary-tinted background as [active]
:host([disabled]) [part="row"]opacity: 0.55
:host([disabled]) [part="children"]opacity: 0.55

cv-treegrid-column

Declares a column definition. Rendered as a visual column header inside cv-treegrid. Not part of the row grid; used by the parent to build the headless column model.

Anatomy

<cv-treegrid-column> (host)
└── <span>
    └── <slot>   ← falls back to [label] attribute text

Attributes

AttributeTypeDefaultDescription
valueString""Column identifier used to match cv-treegrid-cell[column]. Auto-assigned as "column-N" if empty
labelString""Fallback text displayed in the default slot when no slot content is provided
indexNumber0Explicit aria-colindex override; values < 1 or non-finite are ignored
disabledBooleanfalseDisables all cells in this column from navigation
cell-roleString"gridcell"ARIA role for all cells in this column: gridcell | rowheader | columnheader

Slots

SlotDescription
(default)Column header label; falls back to the label attribute value

CSS Parts

PartElementDescription
(none)The column renders an inner <span> but exposes no named parts

CSS Custom Properties

PropertyDefaultDescription
(none)No component-scoped custom properties; layout controlled by host styles

Additionally, component styles depend on theme tokens:

Theme PropertyDefaultDescription
--cv-color-border#2a3245Bottom border color
--cv-color-text#e8ecf6Label text color
--cv-color-surface#141923Column header background base
--cv-color-primary#65d7ffFocus outline color
--cv-space-28pxInline padding

Visual States

Host selectorDescription
:hostdisplay: flex, min-block-size: 36px, font-weight: 600; bottom border separating header from rows
:host([disabled])opacity: 0.55
:host(:focus-visible)outline: 2px solid var(--cv-color-primary) at outline-offset: -2px

cv-treegrid-cell

Represents a single cell within a cv-treegrid-row. The column attribute links it to a cv-treegrid-column by value.

Anatomy

<cv-treegrid-cell> (host)
└── <slot>                                        ← normal cell content

<cv-treegrid-cell tree-control> (host)
└── <span part="tree">
    ├── <span part="guide">
    ├── <button part="toggle" aria-expanded>     ← branch rows only; hidden spacer on leaves
    │   └── <span part="toggle-icon">
    └── <span part="content">
        └── <slot>                               ← cell content

Attributes

AttributeTypeDefaultDescription
columnString""Id of the cv-treegrid-column this cell belongs to; positional fallback used when value is empty or unrecognized
disabledBooleanfalseMarks this specific cell as disabled; excluded from navigation
activeBooleanfalseSet by parent when this cell is the active cell; drives cell-level highlight
selectedBooleanfalseSet by parent when the row containing this cell is selected; drives font-weight: 600
tree-controlBooleanfalseSet by parent on the first rowheader cell, falling back to the first valid cell; renders hierarchy indent and disclosure UI
branchBooleanfalseSet by parent on the tree-control cell when the row has child rows
expandedBooleanfalseSet by parent on the tree-control cell from the row expansion state
levelNumber1Set by parent on the tree-control cell from row aria-level; drives indent and guide placement

rowId is a property-only parent-written value used by the disclosure button event. It is not reflected as an attribute.

Slots

SlotDescription
(default)Cell content

CSS Parts

PartElementDescription
tree<span>Tree-control wrapper for indentation, disclosure, guide, and content
guide<span>Non-interactive hierarchy guide line
toggle<button>Disclosure control; hidden but space-preserving on leaf rows
toggle-icon<span>Chevron glyph inside the disclosure button
content<span>Content wrapper around the default slot when the cell is tree-control

CSS Custom Properties

PropertyDefaultDescription
--cv-treegrid-indent-sizevar(--cv-treegrid-child-indent, 14px)Horizontal indent per tree level
--cv-treegrid-toggle-size22pxInline/block size of the disclosure control
--cv-treegrid-guide-colorvar(--cv-color-border, #2a3245)Hierarchy guide line color
--cv-treegrid-level1Current nesting depth written by the cell from level

Additionally, component styles depend on theme tokens:

Theme PropertyDefaultDescription
--cv-color-text#e8ecf6Cell text color
--cv-color-primary#65d7ffActive cell background tint and focus outline color
--cv-space-28pxInline padding
--cv-space-14pxBlock padding

Visual States

Host selectorDescription
:hostdisplay: block
:host([active])background: var(--cv-color-selected)
:host([selected])font-weight: 600
:host([tree-control])Renders tree affordance wrapper around slotted content
:host([branch])Shows enabled disclosure button when the row is not disabled
:host([expanded])Shows expanded disclosure icon and aria-expanded="true"
:host([disabled])opacity: 0.55
:host(:focus-visible)outline: 2px solid var(--cv-color-primary) at outline-offset: -2px

Out of Scope

  • Async loading of child rows (pagination or lazy fetch)
  • Column sorting or column header click behavior
  • Drag and drop row reordering
  • Multiple cell selection (only row-level selection is supported)
  • Virtual / windowed rendering of large datasets
  • Column resizing or column visibility toggling
  • Row grouping beyond the existing tree hierarchy
  • Inline cell editing

ChromVoid UIKit documentation