Theme
Design your own
On this page (11)
Component Skill — components/data-grid/component-skill.md

Native basis

An ARIA grid of divs: ONE scroll container holds a position: sticky head and a windowed body; head and rows share one CSS grid track template, so locked columns are sticky cells and nothing scrolls in JS. df$.shadcn.dataGrid.setSource(el, records) hands it the data.

Web Platform APIs

position: stickyoverflow-anchor: noneCSS GridIntl.NumberFormatARIA grid

Classes

.data-grid.data-grid-viewport.data-grid-head.data-grid-row.data-grid-header.data-grid-body.data-grid-cell.data-grid-footer

Data attributes

Grid: data-paging (virtual · pages · infinite), data-page-size, data-select, data-parent-field, data-persist / data-persist-prefix / data-persist-key, data-empty-text. Header: data-field, data-sort, data-sort-field, data-type, data-format, data-width, data-filter, data-options, data-locked.

§100,000 orders

Click a header to sort, Shift+click to add a column to the sort. Type in the filter row - every filter runs over all 100,000 rows (text anywhere, case-insensitive; numbers take > 500, <= 20 …). The pin in a header locks the column: it moves into the sticky group and stays while the rest scrolls sideways. Click / Shift+click / Ctrl+click select rows; arrows move cell by cell. The line under the grid subscribes to the grid's store.

§Pages

data-paging='pages' shows data-page-size rows a page and a pager below; the page is config.page (0-based), so sorting or filtering starts again at page 1.

§Infinite scrolling

data-paging='infinite' adds a page as you near the end: first from the records it has, then from load(offset, size) - an async function (here a fake 400 ms network) that returns the next records, or none when there are no more. The grid waits in 'loading' for the first page.

§The store is the query

Everything the grid shows is el.store.value.config - plain JSON. These buttons only write the query (df$.shadcn.dataGrid.query merges into it); the panel below only subscribes to the store. The grid does the rest.

§Custom cells

cells: { field: (el, record) } fills a column with anything - a badge, a meter. Cells are recycled with their rows; the function gets a fresh .data-grid-content element each time.

§A kept view

Every grid keeps its sort, filters, locked columns and open rows - in session storage by default, under a generated key (data-persist-prefix sets its start, data-persist-key the whole key). data-persist='local' keeps them across visits, 'none' keeps nothing; setSource's persist option does the same from code. data-sort on a header is the starting sort. This sandboxed example has no storage (the store falls back to memory), so instead of reloading: sort, filter or lock a column, then mount a fresh grid - it comes back as you left it.

§Loading and empty

A grid without records waits in 'loading' (skeleton rows, aria-busy); a query nothing matches lands in 'empty' with data-empty-text.

§States

Named states via the shared State API, driven per instance through the bound api; the config of every state is the query (filters, sorters, locked, page, expanded, collapsed, selected), merged on each setState and observable as el.store:

  • default - the rows of the query; a query nothing matches lands in empty
  • loading - aria-busy="true" and skeleton rows; a grid without records starts here
  • empty - no rows: the data-empty-text message

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/data-grid-{state}.png.

Machine contract - verified against data-grid.schema.json by bun run verify:

StateTypeValuesDefaultDescription
loadingbooleantrue, falsefalsearia-busy with skeleton rows (setState('loading')).
emptybooleantrue, falsefalseNo rows - shows data-empty-text (setState('empty')).

§API

Generated from data-grid.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type DataGridState = 'default' | 'loading' | 'empty' - setState(name, config) takes the config of the state it names.

StateDescription
default
The rows of the query; a query nothing matches lands in empty.
Config fieldTypeDescription
filters?DataviewFilter[]filters over every record (a tree grid shows a match with its ancestors)
sorters?DataviewSorter[]the sort order - several sorters sort by each in turn
locked?string[]the fields of the locked columns, pinned to the start in this order
page?numberthe page shown, 0-based (data-paging="pages")
expanded?DataviewJsonValue[]tree grid: the ids of the open rows
collapsed?DataviewJsonValue[]tree grid, while filtering: the rows the user closed again
selected?DataviewJsonValue[]the ids of the selected rows
loading
Busy: skeleton rows, aria-busy - a grid without records (or waiting for load()) starts here.
Config fieldTypeDescription
filters?DataviewFilter[]filters over every record (a tree grid shows a match with its ancestors)
sorters?DataviewSorter[]the sort order - several sorters sort by each in turn
locked?string[]the fields of the locked columns, pinned to the start in this order
page?numberthe page shown, 0-based (data-paging="pages")
expanded?DataviewJsonValue[]tree grid: the ids of the open rows
collapsed?DataviewJsonValue[]tree grid, while filtering: the rows the user closed again
selected?DataviewJsonValue[]the ids of the selected rows
empty
No rows: the data-empty-text shows.
Config fieldTypeDescription
filters?DataviewFilter[]filters over every record (a tree grid shows a match with its ancestors)
sorters?DataviewSorter[]the sort order - several sorters sort by each in turn
locked?string[]the fields of the locked columns, pinned to the start in this order
page?numberthe page shown, 0-based (data-paging="pages")
expanded?DataviewJsonValue[]tree grid: the ids of the open rows
collapsed?DataviewJsonValue[]tree grid, while filtering: the rows the user closed again
selected?DataviewJsonValue[]the ids of the selected rows

Every element

MemberDescription
el.api.setState<S extends DataGridState>(name: S, config?: DataGridStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?DataGridStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: DataGridState; config: DataGridStateConfigs[DataGridState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.dataGridApi.setState<S extends DataGridState>(el: HTMLElement, name: S, config?: DataGridStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?DataGridStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.dataGridApi.getState(el: HTMLElement): { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.dataGridApi.render(state: { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.dataGridApi.store(el: HTMLElement): Store<{ name: DataGridState; config: DataGridStateConfigs[DataGridState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: DataGridState; config: DataGridStateConfigs[DataGridState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.dataGridApi.commit<S extends DataGridState>(el: HTMLElement, name: S, config?: DataGridStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?DataGridStateConfigs[S]its config
df$.shadcn.dataGridStates: DataGridState[]The declared states, 'default' first: default, loading, empty.

df$.shadcn.dataGrid

MemberDescription
setSource(target: string | HTMLElement, rows: DataviewRow[], options: DataGridOptions = {}): void
Hand the grid its records. Options: idField ('id'), parentIdField (a tree grid; or data-parent-field), cells ({ field: (el, record, meta) }) custom cell content, load(offset, size) → Promise<records[]> for data-paging="infinite" from a remote source, query ({ sorters, filters, locked, ... }) the starting query, persist ({ area: 'session' | 'local' | 'none', prefix, key }) where the view is kept - a kept view wins over the starting query.
ArgumentTypeDescription
targetstring | HTMLElementthe .data-grid element or its selector
rowsDataviewRow[]the records (a tree grid: a flat list linked by parent id)
optionsDataGridOptions = {}fields, custom cells, a remote loader, the starting view and its persistence
query(target: string | HTMLElement, patch: DataGridQuery): void
Merge into the query: { filters?, sorters?, locked?, page?, expanded?, selected? }.
ArgumentTypeDescription
targetstring | HTMLElementthe .data-grid element or its selector
patchDataGridQuerythe query keys to change
rows(target: string | HTMLElement): DataviewRow[]
The records the query shows.
ArgumentTypeDescription
targetstring | HTMLElementthe .data-grid element or its selector

Returns DataviewRow[] - the records in query order, every page

selected(target: string | HTMLElement): DataviewRow[]
The selected records.
ArgumentTypeDescription
targetstring | HTMLElementthe .data-grid element or its selector

Returns DataviewRow[] - the selected records, in source order

selectAll(target: string | HTMLElement): void
Select every row the query shows (data-select="multiple").
ArgumentTypeDescription
targetstring | HTMLElementthe .data-grid element or its selector
clearSelection(target: string | HTMLElement): void
Select nothing.
ArgumentTypeDescription
targetstring | HTMLElementthe .data-grid element or its selector
expandAll(target: string | HTMLElement): void
Tree grid: open every branch.
ArgumentTypeDescription
targetstring | HTMLElementthe .data-grid element or its selector
collapseAll(target: string | HTMLElement): void
Tree grid: close every branch (while filtering: the ways to the matches too).
ArgumentTypeDescription
targetstring | HTMLElementthe .data-grid element or its selector

Events

EventDescription
data-grid-activate
Fires on Enter on a row - its record and its position in the query.

detail: DataGridActivateDetail

FieldTypeDescription
recordDataviewRowthe row's record
indexnumberits position in the query (every page)

Types

TypeDescription
DataGridActivateDetail
What data-grid-activate carries.
FieldTypeDescription
recordDataviewRowthe row's record
indexnumberits position in the query (every page)
DataGridOptions
What setSource() takes besides the records.
FieldTypeDescription
idField?stringthe id field (default 'id', or data-id-field)
parentIdField?stringa tree grid: the parent-id field (or data-parent-field)
cells?Record<string, (el: HTMLElement, record: DataviewRow, meta: DataGridRowMeta) => void>custom cell content per field: fill the cell's content element
load?(offset: number, size: number) => Promise<DataviewRow[]>data-paging="infinite" from a remote source: the next records from offset (an empty answer ends the loading)
query?DataGridQuerythe starting query (a kept view wins over it)
persist?ViewPersistencewhere the view is kept between visits (default: session storage under a generated key)
DataGridQuery
The grid's query - its state config (merged on every query()).
FieldTypeDescription
filters?DataviewFilter[]filters over every record (a tree grid shows a match with its ancestors)
sorters?DataviewSorter[]the sort order - several sorters sort by each in turn
locked?string[]the fields of the locked columns, pinned to the start in this order
page?numberthe page shown, 0-based (data-paging="pages")
expanded?DataviewJsonValue[]tree grid: the ids of the open rows
collapsed?DataviewJsonValue[]tree grid, while filtering: the rows the user closed again
selected?DataviewJsonValue[]the ids of the selected rows
DataGridRowMeta
Where a record sits - what a custom cell receives.
FieldTypeDescription
depthnumbertree grid: 0 for a root row
hasChildrenbooleantree grid: whether it has child rows
isExpandedbooleantree grid: whether its children show
isMatchbooleanwhether it matches the filters (false for an ancestor shown for a match)
isSelectedbooleanwhether it is selected
parentIdDataviewJsonValue | nulltree grid: its parent's id, null for a root

§CSS view file

/* -- Data Grid --------------------------------------------------- */
/* One scroll container holds a sticky head and the windowed body,   */
/* so the head and every row scroll sideways together and locked     */
/* columns stick with position: sticky - no JS scroll syncing.       */
@layer components {
  .data-grid {
    --data-grid-row-height: 36px;
    --data-grid-template: repeat(4, minmax(8rem, 1fr));
    display: flex;
    flex-direction: column;
    min-height: 12rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: var(--card);
    color: var(--card-foreground);
    font-size: 0.875rem;
    overflow: hidden;
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
  }
  /* the one scroll container: both axes */
  .data-grid-viewport {
    position: relative;
    flex: 1;
    min-height: 0;
    overflow: auto;
    overscroll-behavior: contain;
    /* the windowing owns the scroll position: Firefox's scroll anchoring would follow
       the recycled rows, move scrollTop, re-render and move it again - a scroll
       that runs on by itself (big-data-firefox.e2e) */
    overflow-anchor: none;
    scrollbar-gutter: stable;
  }
  .data-grid-head {
    position: sticky;
    top: 0;
    z-index: 2;
    width: max-content;
    min-width: 100%;
    background: var(--muted);
    color: var(--muted-foreground);
    border-bottom: 1px solid var(--border);
  }
  /* every row - head, filters, body - lays out the same tracks */
  .data-grid-row {
    display: grid;
    grid-template-columns: var(--data-grid-template);
    width: max-content;
    min-width: 100%;
  }
  .data-grid-header,
  .data-grid-cell,
  .data-grid-filter-cell {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    min-width: 0;
    padding-inline: 0.75rem;
    background: inherit;
    &[data-locked] {
      position: sticky;
      z-index: 1;
    }
    &[data-align="end"] {
      justify-content: flex-end;
      font-variant-numeric: tabular-nums;
    }
  }
  /* the last locked column draws the edge the rest scrolls under */
  .data-grid[data-has-locked] :is(.data-grid-header, .data-grid-cell, .data-grid-filter-cell)[data-locked]:not(:has(~ [data-locked])) {
    box-shadow: 1px 0 0 var(--border), 6px 0 8px -6px color-mix(in oklch, var(--foreground) 25%, transparent);
  }
  .data-grid-header {
    height: var(--data-grid-row-height);
    font-weight: 500;
    font-size: 0.8125rem;
    background: var(--muted);
    cursor: pointer;
    user-select: none;
    white-space: nowrap;
    &[data-type="number"] {
      justify-content: flex-end;
    }
    &[data-sortable="false"] {
      cursor: default;
    }
    /* the sort direction, and the priority of a multisort (1, 2, …) */
    &[aria-sort]::after {
      content: "";
      flex: none;
      width: 0.5rem;
      height: 0.5rem;
      border-inline-end: 1.5px solid currentColor;
      border-bottom: 1.5px solid currentColor;
      rotate: 45deg;
      translate: 0 -0.125rem;
      color: var(--foreground);
    }
    &[aria-sort="ascending"]::after {
      rotate: -135deg;
      translate: 0 0.125rem;
    }
    &[data-sort-index]::before {
      content: attr(data-sort-index);
      order: 2;
      font-size: 0.6875rem;
      font-variant-numeric: tabular-nums;
      color: var(--foreground);
    }
    &[aria-sort] {
      color: var(--foreground);
    }
    &:hover {
      color: var(--foreground);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }
  }
  /* the lock toggle: a pin that shows on hover / focus and stays when locked */
  .data-grid-pin {
    order: 3;
    flex: none;
    display: grid;
    place-items: center;
    width: 1.375rem;
    height: 1.375rem;
    margin-inline-start: auto;
    padding: 0;
    border: 0;
    border-radius: var(--radius-sm);
    background: transparent;
    color: var(--muted-foreground);
    cursor: pointer;
    opacity: 0;
    transition: opacity 120ms ease;
    /* the icon is a mask over currentColor (pin), so it follows the theme */
    &::before {
      content: "";
      width: 0.875rem;
      height: 0.875rem;
      background: currentColor;
      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 17v5'/%3E%3Cpath d='M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z'/%3E%3C/svg%3E") center / contain no-repeat;
      rotate: 45deg;
    }
    &:hover {
      background: var(--accent);
      color: var(--accent-foreground);
    }
  }
  .data-grid-header:is(:hover, :focus-within, [data-locked]) .data-grid-pin {
    opacity: 1;
  }
  .data-grid-header[data-locked] .data-grid-pin {
    color: var(--primary);
    &::before {
      rotate: 0deg;
    }
  }
  .data-grid-header[data-type="number"] .data-grid-pin {
    order: -1;
    margin-inline: 0 auto;
  }
  /* -- the filter row ------------------------------------------ */
  .data-grid-filters {
    border-top: 1px solid var(--border);
    background: var(--card);
  }
  .data-grid-filter-cell {
    height: calc(var(--data-grid-row-height) + 4px);
    padding-inline: 0.375rem;
    background: var(--card);
  }
  .data-grid-filter {
    width: 100%;
    min-width: 0;
    height: 1.75rem;
    padding-inline: 0.5rem;
    border: 1px solid var(--input);
    border-radius: var(--radius-sm);
    background: var(--background);
    color: var(--foreground);
    font: inherit;
    font-size: 0.8125rem;
    &::placeholder {
      color: var(--muted-foreground);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -1px;
      border-color: var(--ring);
    }
  }
  /* -- the windowed body ----------------------------------------- */
  .data-grid-body {
    position: relative;
    width: max-content;
    min-width: 100%;
    contain: layout paint;
  }
  .data-grid-rows {
    position: absolute;
    top: 0;
    inset-inline-start: 0;
    width: max-content;
    min-width: 100%;
    will-change: translate;
  }
  .data-grid-body .data-grid-row {
    height: var(--data-grid-row-height);
    background: var(--card);
    border-bottom: 1px solid var(--border);
    cursor: default;
    &:hover {
      background: color-mix(in oklch, var(--accent) 60%, var(--card));
    }
    &[aria-selected="true"] {
      background: color-mix(in oklch, var(--primary) 12%, var(--card));
    }
  }
  .data-grid-cell {
    white-space: nowrap;
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }
  }
  .data-grid-content {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  /* -- tree grid: the first column indents and carries the toggle --- */
  .data-grid-toggle {
    flex: none;
    display: grid;
    place-items: center;
    width: 1.25rem;
    height: 1.25rem;
    margin-inline-start: calc(var(--depth, 0) * 1.25rem);
    border-radius: var(--radius-sm);
    color: var(--muted-foreground);
    cursor: pointer;
    &::before {
      content: "";
      width: 0.375rem;
      height: 0.375rem;
      border-inline-end: 1.5px solid currentColor;
      border-bottom: 1.5px solid currentColor;
      rotate: -45deg;
      transition: rotate 150ms ease;
    }
    &[data-leaf] {
      cursor: default;
      &::before {
        content: none;
      }
    }
    &:not([data-leaf]):hover {
      background: var(--accent);
      color: var(--accent-foreground);
    }
  }
  .data-grid-row[aria-expanded="true"] .data-grid-toggle::before {
    rotate: 45deg;
  }
  /* -- footer: counts + pager --------------------------------------- */
  .data-grid-footer {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem 1rem;
    padding: 0.5rem 0.75rem;
    border-top: 1px solid var(--border);
    color: var(--muted-foreground);
    font-size: 0.8125rem;
    font-variant-numeric: tabular-nums;
    &:empty {
      display: none;
    }
  }
  .data-grid-pager {
    display: flex;
    align-items: center;
    gap: 0.25rem;
  }
  .data-grid-page-label {
    padding-inline: 0.5rem;
    color: var(--foreground);
  }
  .data-grid-page-btn {
    display: grid;
    place-items: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--background);
    color: var(--foreground);
    cursor: pointer;
    /* chevrons as masks over currentColor */
    &::before {
      content: "";
      width: 1rem;
      height: 1rem;
      background: currentColor;
      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
    }
    &[data-page="first"]::before { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m11 17-5-5 5-5'/%3E%3Cpath d='m18 17-5-5 5-5'/%3E%3C/svg%3E"); }
    &[data-page="next"]::before { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E"); }
    &[data-page="last"]::before { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 17 5-5-5-5'/%3E%3Cpath d='m13 17 5-5-5-5'/%3E%3C/svg%3E"); }
    &:dir(rtl)::before {
      scale: -1 1;
    }
    &:hover:not(:disabled) {
      background: var(--accent);
      color: var(--accent-foreground);
    }
    &:disabled {
      opacity: 0.5;
      cursor: default;
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
  }
  /* infinite: the next page is on its way */
  .data-grid[data-loading-more] .data-grid-status::after {
    content: " · loading more…";
  }
  /* -- loading and empty -------------------------------------------- */
  .data-grid[data-state="loading"],
  .data-grid[data-state="empty"] {
    & .data-grid-rows {
      display: none;
    }
    & .data-grid-body {
      height: auto !important; /* nothing to scroll in these states */
      min-height: calc(var(--data-grid-row-height) * 4);
    }
  }
  .data-grid[data-state="loading"] .data-grid-body {
    background: repeating-linear-gradient(
      to bottom,
      var(--muted) 0,
      var(--muted) calc(var(--data-grid-row-height) - 1px),
      transparent calc(var(--data-grid-row-height) - 1px),
      transparent var(--data-grid-row-height)
    );
    opacity: 0.6;
    animation: data-grid-pulse 1.6s ease-in-out infinite;
  }
  /* attr() reads the body's own attribute: init copies the grid's
     data-empty-text onto it */
  .data-grid[data-state="empty"] .data-grid-body::after {
    content: attr(data-empty-text);
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    color: var(--muted-foreground);
  }
  @keyframes data-grid-pulse {
    0%, 100% { opacity: 0.6; }
    50% { opacity: 0.3; }
  }
}
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .data-grid,
    .data-grid *,
    .data-grid *::before,
    .data-grid *::after,
    .data-grid[data-state="loading"] .data-grid-body {
      transition: none;
      animation: none;
      scroll-behavior: auto;
    }
  }
}
@media (prefers-contrast: more) {
  @layer components {
    .data-grid,
    .data-grid-head,
    .data-grid-body .data-grid-row,
    .data-grid-filter,
    .data-grid-page-btn {
      border-color: var(--muted-foreground);
    }
    .data-grid-header,
    .data-grid-footer {
      color: var(--foreground);
    }
  }
}
@media (forced-colors: active) {
  @layer components {
    .data-grid,
    .data-grid-head,
    .data-grid-body .data-grid-row,
    .data-grid-filter,
    .data-grid-page-btn {
      border-color: ButtonText;
    }
    .data-grid-body .data-grid-row[aria-selected="true"] {
      background: Highlight;
      color: HighlightText;
    }
    .data-grid-cell:focus-visible,
    .data-grid-header:focus-visible {
      outline-color: Highlight;
    }
    .data-grid-header[aria-sort]::after,
    .data-grid-toggle::before {
      border-color: ButtonText;
    }
    /* masked icons paint their background - keep it the text color */
    .data-grid-pin::before,
    .data-grid-page-btn::before {
      forced-color-adjust: none;
      background: ButtonText;
    }
  }
}

§JS view file

// -- Data Grid ------------------------------------------------
// A grid over any number of records: only the rows on screen exist in the
// DOM (shared windowing, src/shared/virtual.ts) and every query runs locally
// over all rows through defuss-dataview (src/shared/dataview.ts) - multisort,
// column filters, locked columns, pages, infinite loading, selection. With
// data-parent-field every row may have a parent: the grid becomes an ARIA
// treegrid that still filters, sorts and pages.
//
// The query IS the state's config (AGENTS.md "State through stores"):
// el.store.value.config = { filters, sorters, locked, page, expanded,
// collapsed, selected } - plain JSON any code can subscribe to or set. The
// view (sort, filters, locked columns, open rows) survives a reload: session
// storage by default, local storage or nothing per instance (data-persist,
// data-persist-prefix / -key, or setSource's persist option).
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import {
  defussGlobals,
  defussQuery,
  textLocale,
  componentState,
  bindComponent,
  persisted,
  viewPersistence,
  dataSource,
  parseFilter,
  filterText,
  cycleSort,
  virtualWindow,
  sizerHeight,
  scrollIntoViewTop,
} from '../../../shared/state-api.js';
import type { DataviewFilter, DataviewJsonValue, DataviewRow, DataviewSorter } from '../../../shared/dataview.js';
import type { ViewPersistence } from '../../../shared/store.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** The grid's query - its state config (merged on every query()). */
type DataGridQuery = {
  /** filters over every record (a tree grid shows a match with its ancestors) */
  filters?: DataviewFilter[];
  /** the sort order - several sorters sort by each in turn */
  sorters?: DataviewSorter[];
  /** the fields of the locked columns, pinned to the start in this order */
  locked?: string[];
  /** the page shown, 0-based (data-paging="pages") */
  page?: number;
  /** tree grid: the ids of the open rows */
  expanded?: DataviewJsonValue[];
  /** tree grid, while filtering: the rows the user closed again */
  collapsed?: DataviewJsonValue[];
  /** the ids of the selected rows */
  selected?: DataviewJsonValue[];
};
/** Where a record sits - what a custom cell receives. */
interface DataGridRowMeta {
  /** tree grid: 0 for a root row */
  depth: number;
  /** tree grid: whether it has child rows */
  hasChildren: boolean;
  /** tree grid: whether its children show */
  isExpanded: boolean;
  /** whether it matches the filters (false for an ancestor shown for a match) */
  isMatch: boolean;
  /** whether it is selected */
  isSelected: boolean;
  /** tree grid: its parent's id, null for a root */
  parentId: DataviewJsonValue | null;
}
/** What setSource() takes besides the records. */
interface DataGridOptions {
  /** the id field (default 'id', or data-id-field) */
  idField?: string;
  /** a tree grid: the parent-id field (or data-parent-field) */
  parentIdField?: string;
  /** custom cell content per field: fill the cell's content element */
  cells?: Record<string, (el: HTMLElement, record: DataviewRow, meta: DataGridRowMeta) => void>;
  /** data-paging="infinite" from a remote source: the next records from offset (an empty answer ends the loading) */
  load?: (offset: number, size: number) => Promise<DataviewRow[]>;
  /** the starting query (a kept view wins over it) */
  query?: DataGridQuery;
  /** where the view is kept between visits (default: session storage under a generated key) */
  persist?: ViewPersistence;
}
/** What data-grid-activate carries. */
interface DataGridActivateDetail {
  /** the row's record */
  record: DataviewRow;
  /** its position in the query (every page) */
  index: number;
}
const dataGridStates = ['default', 'loading', 'empty'];
/** setState() configs per state - the config IS the grid's query, merged into the stored one ({ page: 3 } keeps the filters). */
export interface DataGridStateConfigs {
  /** The rows of the query; a query nothing matches lands in empty. */
  default: DataGridQuery;
  /** Busy: skeleton rows, aria-busy - a grid without records (or waiting for load()) starts here. */
  loading: DataGridQuery;
  /** No rows: the data-empty-text shows. */
  empty: DataGridQuery;
}
/** the part that is persisted (selection and the open page are per visit) */
const SAVED_KEYS = ['filters', 'sorters', 'locked', 'expanded'];
const numberFormat = new Map();
/** a cell value as text: data-format="number" | "currency:EUR" | "percent" | "date" - in the text's locale (the grid's nearest [lang], else 'en') */
function format(value, spec, el) {
  if (value == null) return '';
  if (!spec) return String(value);
  const locale = textLocale(el);
  if (spec === 'date') {
    const date = value instanceof Date ? value : new Date(value);
    return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleDateString(locale);
  }
  const key = `${locale}|${spec}`;
  if (!numberFormat.has(key)) {
    const [style, currency] = spec.split(':');
    numberFormat.set(
      key,
      new Intl.NumberFormat(locale, style === 'currency' ? { style, currency: currency || 'USD' } : style === 'percent' ? { style, maximumFractionDigits: 1 } : {}),
    );
  }
  return typeof value === 'number' ? numberFormat.get(key).format(value) : String(value);
}
/** the header row: the first row of the head */
const headerRowOf = (root) => dfDollar(root).find('.data-grid-head > .data-grid-row').get(0);
/** the authored column headers of a grid (or of a detached copy), in DOM order */
const headersOf = (root) => {
  const row = headerRowOf(root);
  return row ? (dfDollar(row).children('.data-grid-header').toArray()) : [];
};
/** The columns, read once from the authored headers (authored order). */
function readColumns(grid) {
  return headersOf(grid).map((el) => ({
    el,
    field: el.dataset.field,
    filter: el.dataset.filter || '',
    type: el.dataset.type === 'number' ? 'number' : 'text',
    // data-format="" keeps the raw value (an id needs no thousands separator)
    format: el.hasAttribute('data-format') ? el.dataset.format : el.dataset.type === 'number' ? 'number' : '',
    width: el.dataset.width || 'minmax(8rem, 1fr)',
    align: el.dataset.align || (el.dataset.type === 'number' ? 'end' : ''),
    sortable: el.dataset.sortable !== 'false',
    // sort by another field than the one shown (a priority's rank, not its label)
    sortField: el.dataset.sortField || el.dataset.field,
    options: el.dataset.options ? el.dataset.options.split(',').map((o) => o.trim()) : null,
  }));
}
/**
 * The persisted view: where (viewPersistence: data-persist, -prefix, -key,
 * or the config) and what was kept there. Re-attaching (setSource with a
 * persist option) moves the instance to the new place.
 */
function attachPersistence(grid, config) {
  grid._saved?.destroy();
  const where = viewPersistence(grid, 'data-grid', String(dfDollar('.data-grid').toArray().indexOf(grid)), config || {});
  grid._saved = where
    ? persisted<Record<string, unknown>>(where.key, {}, { area: where.area, validate: (v): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v) })
    : null;
  const kept = {};
  for (const k of SAVED_KEYS) if (Array.isArray(grid._saved?.value[k])) kept[k] = grid._saved.value[k];
  return kept;
}
/** the authored sort: data-sort="asc|desc" on headers, in column order */
const authoredSort = (grid) =>
  grid._columns.filter((c) => c.el.dataset.sort === 'asc' || c.el.dataset.sort === 'desc').map((c) => ({ field: c.sortField, direction: c.el.dataset.sort }));
/** locked fields first (in authored order), then the rest */
function ordered(items, locked, fieldOf) {
  const set = new Set(locked || []);
  return [...items.filter((i) => set.has(fieldOf(i))), ...items.filter((i) => !set.has(fieldOf(i)))];
}
/**
 * The markup of a state, for render() AND the live element (one function):
 * header order (locked columns first), data-locked, aria-sort and the sort
 * priority. 'default' with the authored config IS the authored markup.
 */
function applyMarkup(root, state) {
  const config = state.config || {};
  const headers = root._columns ? root._columns.map((c) => c.el) : headersOf(root);
  const row = headerRowOf(root);
  const locked = new Set(config.locked || []);
  const sorters = config.sorters || [];
  const order = ordered(headers, config.locked, (el) => el.dataset.field);
  // only move what is out of place: an authored order stays untouched
  if (row && order.some((el, i) => dfDollar(row).children('.data-grid-header').get(i) !== el)) {
    order.forEach((el) => dfDollar(row).append(el));
  }
  for (const el of headers) {
    const at = sorters.findIndex((s) => s.field === (el.dataset.sortField || el.dataset.field));
    const dir = at < 0 ? null : (sorters[at].direction || sorters[at].dir || 'asc');
    dfDollar(el)
      .attr('data-locked', locked.has(el.dataset.field) ? '' : null)
      .attr('aria-sort', dir ? (dir === 'asc' ? 'ascending' : 'descending') : null)
      .attr('data-sort-index', dir && sorters.length > 1 ? String(at + 1) : null);
  }
  dfDollar(root)
    .attr('data-state', state.name)
    .attr('aria-busy', state.name === 'loading' ? 'true' : null);
}
// -- the query --------------------------------------------------------------
// the config being shown: set first thing in apply (the store records it
// only AFTER the DOM work - reading the store there would be one step behind)
const configOf = (grid) => grid._config ?? grid.store?.value.config ?? {};
const isTree = (grid) => !!grid._source?.tree;
const paging = (grid) => grid.dataset.paging || 'virtual';
const pageSize = (grid) => Math.max(1, parseInt(grid.dataset.pageSize || '50', 10) || 50);
/** evaluate the config's query (cached per query inside the source) */
function evaluate(grid, config) {
  if (!grid._source) return (grid._result = { entries: [], totalRows: 0, matchedRows: 0, visibleRows: 0 });
  return (grid._result = grid._source.query({
    filters: config.filters,
    sorters: config.sorters,
    expanded: config.expanded,
    collapsed: config.collapsed,
  }));
}
/** the entries the body shows: everything (virtual), one page, or the pages loaded */
function shown(grid, config) {
  const entries = grid._result?.entries ?? [];
  const mode = paging(grid);
  if (mode === 'virtual') return entries;
  const size = pageSize(grid);
  const page = Math.max(0, config.page || 0);
  return mode === 'pages' ? entries.slice(page * size, (page + 1) * size) : entries.slice(0, (page + 1) * size);
}
const pageCount = (grid) => Math.max(1, Math.ceil((grid._result?.entries.length ?? 0) / pageSize(grid)));
// -- layout -----------------------------------------------------------------
const parts = (grid) => grid._parts;
const rowHeight = (grid) => grid._rowHeight;
/** column template + the sticky offsets of the locked columns */
function layoutColumns(grid, config) {
  const columns = ordered(grid._columns, config.locked, (c) => c.field);
  grid._order = columns;
  grid.style.setProperty('--data-grid-template', columns.map((c) => c.width).join(' '));
  const locked = new Set(config.locked || []);
  // offsets are measured: widths may be rem, minmax(), fr
  let left = 0;
  grid._lockLeft = {};
  for (const column of columns) {
    if (!locked.has(column.field)) {
      column.el.style.removeProperty('inset-inline-start');
      continue;
    }
    grid._lockLeft[column.field] = left;
    column.el.style.insetInlineStart = `${left}px`;
    left += column.el.getBoundingClientRect().width;
  }
  grid.toggleAttribute('data-has-locked', locked.size > 0);
}
/** the filter row (columns with data-filter): built once, kept in step */
function buildFilters(grid) {
  if (!grid._columns.some((c) => c.filter) || grid._parts.filters) return;
  const row = document.createElement('div');
  row.className = 'data-grid-row data-grid-filters';
  row.setAttribute('role', 'row');
  grid._parts.filters = row;
  dfDollar(grid._parts.head).append(row);
}
function syncFilters(grid, config) {
  const row = grid._parts.filters;
  if (!row) return;
  const filters = config.filters || [];
  const locked = new Set(config.locked || []);
  // rebuilt only when the column order changed; values are properties
  const key = grid._order.map((c) => c.field).join('|');
  if (row._key !== key) {
    row._key = key;
    row.textContent = '';
    for (const column of grid._order) {
      const cell = document.createElement('div');
      cell.className = 'data-grid-filter-cell';
      cell.setAttribute('role', 'gridcell');
      cell.dataset.field = column.field;
      if (column.filter) {
        const label = `Filter ${column.el.textContent.trim()}`;
        let input;
        if (column.filter === 'select') {
          input = document.createElement('select');
          const options = column.options || distinct(grid, column.field);
          const all = document.createElement('option');
          all.value = '';
          all.textContent = 'All';
          input.append(all, ...options.map((o) => Object.assign(document.createElement('option'), { value: o, textContent: o })));
        } else {
          input = document.createElement('input');
          input.type = 'search';
          input.placeholder = column.type === 'number' ? '> 100' : 'Filter…';
          input.inputMode = column.type === 'number' ? 'decimal' : 'search';
          input.autocomplete = 'off';
        }
        input.className = 'data-grid-filter';
        input.dataset.field = column.field;
        input.dataset.kind = column.filter === 'select' ? 'select' : column.type;
        input.setAttribute('aria-label', label);
        cell.append(input);
      }
      row.append(cell);
    }
  }
  for (const cell of dfDollar(row).children('.data-grid-filter-cell').toArray()) {
    const field = cell.dataset.field;
    if (locked.has(field)) cell.style.insetInlineStart = `${grid._lockLeft[field] ?? 0}px`;
    else cell.style.removeProperty('inset-inline-start');
    cell.toggleAttribute('data-locked', locked.has(field));
    const input = cell.firstElementChild as HTMLInputElement | null;
    // never under the cursor: the person is typing in it
    if (input && input !== document.activeElement) input.value = filterText(filters.find((f) => f.field === field));
  }
}
/** the values a select filter offers (first 100 distinct, sorted) */
function distinct(grid, field) {
  const seen = new Set();
  for (const row of grid._source?.rows ?? []) {
    if (row[field] != null) seen.add(String(row[field]));
    if (seen.size >= 100) break;
  }
  return [...seen].sort();
}
/** a pin button per header: lock / unlock the column */
function buildPins(grid) {
  for (const column of grid._columns) {
    if (column.el.dataset.lockable === 'false' || dfDollar(column.el).children('.data-grid-pin').get(0)) continue;
    const pin = document.createElement('button');
    pin.type = 'button';
    pin.className = 'data-grid-pin';
    pin.tabIndex = -1;
    pin.dataset.field = column.field;
    pin.setAttribute('aria-label', `Lock ${column.el.textContent.trim()}`);
    dfDollar(column.el).append(pin);
  }
}
// -- rendering --------------------------------------------------------------
/** fill one body cell */
function fillCell(grid, cell, column, entry, first) {
  const record = entry.row;
  cell.dataset.field = column.field;
  cell.toggleAttribute('data-locked', column.field in grid._lockLeft);
  if (column.field in grid._lockLeft) cell.style.insetInlineStart = `${grid._lockLeft[column.field]}px`;
  else cell.style.removeProperty('inset-inline-start');
  if (column.align) cell.dataset.align = column.align;
  else delete cell.dataset.align;
  cell.textContent = '';
  if (first && isTree(grid)) {
    cell.style.setProperty('--depth', String(entry.meta.depth));
    const toggle = document.createElement('span');
    toggle.className = 'data-grid-toggle';
    toggle.setAttribute('aria-hidden', 'true');
    if (!entry.meta.hasChildren) toggle.dataset.leaf = '';
    cell.append(toggle);
  } else {
    cell.style.removeProperty('--depth');
  }
  const custom = grid._cells?.[column.field];
  if (custom) {
    const host = document.createElement('span');
    host.className = 'data-grid-content';
    custom(host, record, entry.meta);
    cell.append(host);
  } else {
    const text = document.createElement('span');
    text.className = 'data-grid-content';
    text.textContent = format(record[column.field], column.format, grid);
    cell.append(text);
  }
}
/** writes the window of rows that belongs at the current scroll position */
function renderRows(grid) {
  const { viewport, body, pool } = parts(grid);
  if (!pool || !grid._order) return;
  const config = configOf(grid);
  const entries = grid._shown || [];
  const total = entries.length;
  const headH = grid._parts.head.offsetHeight;
  const view = Math.max(rowHeight(grid), viewport.clientHeight - headH);
  const win = virtualWindow(Math.max(0, viewport.scrollTop), view, rowHeight(grid), total);
  body.style.height = `${sizerHeight(total, rowHeight(grid))}px`;
  while (pool.children.length < win.count) {
    const row = document.createElement('div');
    row.className = 'data-grid-row';
    row.setAttribute('role', 'row');
    pool.append(row);
  }
  while (pool.children.length > win.count) pool.lastElementChild.remove();
  pool.style.translate = `0 ${win.shift}px`;
  const selected = grid._selected;
  const base = paging(grid) === 'pages' ? Math.max(0, config.page || 0) * pageSize(grid) : 0;
  const headRows = grid._parts.filters ? 2 : 1;
  const focus = grid._focus;
  for (let i = 0; i < pool.children.length; i++) {
    const row = pool.children[i];
    const index = win.first + i;
    const entry = entries[index];
    const key = `${grid._gen}:${index}`;
    if (row._key !== key) {
      row._key = key;
      row._index = index;
      row.dataset.index = String(index);
      row.setAttribute('aria-rowindex', String(base + index + headRows + 1));
      while (row.children.length < grid._order.length) {
        const cell = document.createElement('div');
        cell.className = 'data-grid-cell';
        cell.setAttribute('role', 'gridcell');
        row.append(cell);
      }
      while (row.children.length > grid._order.length) row.lastElementChild.remove();
      grid._order.forEach((column, c) => {
        fillCell(grid, row.children[c], column, entry, c === 0);
        row.children[c].setAttribute('aria-colindex', String(c + 1));
      });
      if (isTree(grid)) {
        row.setAttribute('aria-level', String(entry.meta.depth + 1));
        if (entry.meta.hasChildren) row.setAttribute('aria-expanded', String(entry.meta.isExpanded));
        else row.removeAttribute('aria-expanded');
      }
    }
    const id = entry.row[grid._source.idField];
    if (grid.dataset.select) row.setAttribute('aria-selected', String(selected.has(id)));
    else row.removeAttribute('aria-selected');
    for (let c = 0; c < row.children.length; c++) {
      row.children[c].tabIndex = focus.row === index && focus.col === c ? 0 : -1;
    }
  }
}
/** the counts and the pager below the rows */
function renderFooter(grid, config) {
  const count = (n) => format(n, 'number', grid);
  const footer = grid._parts.footer;
  if (!footer) return;
  const result = grid._result;
  const shownRows = grid._shown?.length ?? 0;
  const visible = result.entries.length;
  const filtered = (config.filters || []).length > 0;
  const mode = paging(grid);
  let text;
  if (isTree(grid)) {
    text = `${count(visible)} rows shown`;
    text += filtered ? ` · ${count(result.matchedRows)} of ${count(result.totalRows)} match` : ` of ${count(result.totalRows)}`;
  } else {
    text = filtered ? `${count(visible)} of ${count(result.totalRows)} rows match` : `${count(visible)} rows`;
  }
  if (mode === 'pages' && visible) {
    const start = Math.max(0, config.page || 0) * pageSize(grid);
    text = `${count(start + 1)}–${count(start + shownRows)} of ${text}`;
  }
  if (mode === 'infinite' && visible) text = `${count(shownRows)} loaded · ${text}`;
  if (grid._selected.size) text += ` · ${count(grid._selected.size)} selected`;
  if (!grid._parts.status) {
    const status = document.createElement('span');
    status.className = 'data-grid-status';
    status.setAttribute('role', 'status');
    footer.append(status);
    grid._parts.status = status;
  }
  grid._parts.status.textContent = text;
  if (mode !== 'pages') return;
  if (!grid._parts.pager) {
    const pager = document.createElement('div');
    pager.className = 'data-grid-pager';
    for (const [act, name] of [['first', 'First page'], ['prev', 'Previous page'], ['label'], ['next', 'Next page'], ['last', 'Last page']]) {
      if (act === 'label') {
        const label = document.createElement('span');
        label.className = 'data-grid-page-label';
        pager.append(label);
        continue;
      }
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'data-grid-page-btn';
      button.dataset.page = act;
      button.setAttribute('aria-label', name);
      pager.append(button);
    }
    footer.append(pager);
    grid._parts.pager = pager;
  }
  const page = Math.max(0, config.page || 0);
  const pages = pageCount(grid);
  dfDollar(grid._parts.pager).find('.data-grid-page-label').get(0).textContent = `Page ${count(page + 1)} of ${count(pages)}`;
  for (const button of dfDollar(grid._parts.pager).find<HTMLButtonElement>('[data-page]').toArray()) {
    const back = button.dataset.page === 'first' || button.dataset.page === 'prev';
    button.disabled = back ? page <= 0 : page >= pages - 1;
  }
}
/**
 * The DOM side of a config: evaluate the query, lay out the columns, fill
 * the filter row, render the window and the footer. Returns the row count.
 */
function refresh(grid, config, previous) {
  grid._config = config;
  evaluate(grid, config);
  grid._selected = new Set(config.selected || []);
  grid._shown = shown(grid, config);
  layoutColumns(grid, config);
  syncFilters(grid, config);
  grid._gen = (grid._gen || 0) + 1;
  const prev = previous?.config || {};
  // a new query starts at the top
  const moved = ['filters', 'sorters', 'page'].some((k) => JSON.stringify(prev[k] ?? null) !== JSON.stringify(config[k] ?? null));
  if (moved && paging(grid) !== 'infinite') grid._parts.viewport.scrollTop = 0;
  if (moved) grid._focus = { row: grid._focus.row < 0 ? -1 : 0, col: grid._focus.col };
  grid.setAttribute('aria-rowcount', String((grid._result.entries.length || 0) + (grid._parts.filters ? 2 : 1)));
  grid.setAttribute('aria-colcount', String(grid._columns.length));
  renderRows(grid);
  renderFooter(grid, config);
  return grid._result.entries.length;
}
/**
 * UI side of setState: 'default' shows the rows of the config's query (none
 * match → it lands in 'empty'), 'loading' marks the grid busy (skeleton
 * rows), 'empty' shows data-empty-text. Every state keeps the query.
 */
function triggerStateChange(grid, state, previous) {
  grid._config = state.config;
  let name = state.name;
  if (name !== 'loading' && grid._parts) {
    const rows = refresh(grid, state.config, previous);
    if (name === 'default' && !rows) name = 'empty';
  }
  applyMarkup(grid, { name, config: state.config });
  grid.dataset.stateName = name;
  if (grid._saved) {
    const keep = {};
    for (const k of SAVED_KEYS) if (state.config[k] !== undefined) keep[k] = state.config[k];
    grid._saved.set(keep);
  }
}
/** Registry-level API; pass the grid explicitly. Unknown names throw. */
export const dataGridApi = componentState({
  component: 'data-grid',
  states: dataGridStates,
  // a config merges: { page: 3 } keeps the filters
  mergeConfig: true,
  apply: (grid, state, previous) => triggerStateChange(grid, state, previous),
  markup: (el, state) => applyMarkup(el, state),
});
df$.dataGridApi = dataGridApi;
df$.dataGridStates = dataGridStates;
// -- interaction --------------------------------------------------------------
/** merge into the query (the store applies it) */
const query = (grid, patch) => dataGridApi.setState(grid, grid.store.value.name === 'loading' ? 'loading' : 'default', patch);
function toggleRow(grid, index, mode) {
  const entry = grid._shown[index];
  if (!entry || !grid.dataset.select) return;
  const id = entry.row[grid._source.idField];
  let next;
  if (grid.dataset.select === 'single' || mode === 'only') next = grid._selected.has(id) && grid._selected.size === 1 && mode !== 'only' ? [] : [id];
  else if (mode === 'range' && grid._anchor != null) {
    const from = Math.min(grid._anchor, index);
    const to = Math.max(grid._anchor, index);
    next = [...new Set([...grid._selected, ...grid._shown.slice(from, to + 1).map((e) => e.row[grid._source.idField])])];
  } else {
    next = grid._selected.has(id) ? [...grid._selected].filter((x) => x !== id) : [...grid._selected, id];
  }
  if (mode !== 'range') grid._anchor = index;
  query(grid, { selected: next });
}
/** every row the query shows (all pages) */
function selectAll(grid) {
  const idField = grid._source?.idField ?? 'id';
  query(grid, { selected: (grid._result?.entries ?? []).map((e) => e.row[idField]) });
}
function toggleExpand(grid, index, open?: boolean) {
  const entry = grid._shown[index];
  if (!entry?.meta.hasChildren) return;
  const id = entry.row[grid._source.idField];
  const want = open ?? !entry.meta.isExpanded;
  if (want === entry.meta.isExpanded) return;
  const config = configOf(grid);
  if ((config.filters || []).length) {
    // while filtering every branch with a match is open; closing one is an exception
    const collapsed = new Set(config.collapsed || []);
    if (want) collapsed.delete(id);
    else collapsed.add(id);
    query(grid, { collapsed: [...collapsed] });
  } else {
    const expanded = new Set(config.expanded || []);
    if (want) expanded.add(id);
    else expanded.delete(id);
    query(grid, { expanded: [...expanded] });
  }
}
/** move the active cell, keep it on screen, focus it */
function focusCell(grid, row, col) {
  const total = grid._shown.length;
  row = Math.max(-1, Math.min(total - 1, row));
  col = Math.max(0, Math.min(grid._order.length - 1, col));
  grid._focus = { row, col };
  const { viewport } = parts(grid);
  if (row >= 0) {
    const view = viewport.clientHeight - grid._parts.head.offsetHeight;
    viewport.scrollTop = scrollIntoViewTop(row, viewport.scrollTop, view, rowHeight(grid), total);
  }
  renderRows(grid);
  for (const [c, header] of grid._order.entries()) header.el.tabIndex = row === -1 && c === col ? 0 : -1;
  const target = row === -1
    ? grid._order[col].el
    : dfDollar(grid._parts.pool).children('.data-grid-row').toArray().find((r) => r._index === row)?.children[col];
  target?.focus({ preventScroll: row === -1 });
}
function onKeydown(grid, e) {
  if (e.target.closest?.('.data-grid-filters, .data-grid-footer')) return;
  const { row, col } = grid._focus;
  const page = Math.max(1, Math.floor((grid._parts.viewport.clientHeight - grid._parts.head.offsetHeight) / rowHeight(grid)) - 1);
  const last = grid._shown.length - 1;
  const entry = row >= 0 ? grid._shown[row] : null;
  let next = null;
  switch (e.key) {
    case 'ArrowDown': next = [Math.min(last, row + 1), col]; break;
    case 'ArrowUp': next = [Math.max(-1, row - 1), col]; break;
    case 'ArrowRight':
      if (isTree(grid) && entry && col === 0 && entry.meta.hasChildren && !entry.meta.isExpanded) { toggleExpand(grid, row, true); next = [row, 0]; }
      else next = [row, col + 1];
      break;
    case 'ArrowLeft':
      if (isTree(grid) && entry && col === 0) {
        if (entry.meta.hasChildren && entry.meta.isExpanded) { toggleExpand(grid, row, false); next = [row, 0]; }
        else if (entry.meta.parentId != null) {
          const parent = grid._shown.findIndex((x) => x.row[grid._source.idField] === entry.meta.parentId);
          next = [parent >= 0 ? parent : row, 0];
        } else next = [row, 0];
      } else next = [row, col - 1];
      break;
    case 'Home': next = e.ctrlKey || e.metaKey ? [Math.min(row, 0), col] : [row, 0]; break;
    case 'End': next = e.ctrlKey || e.metaKey ? [last, col] : [row, grid._order.length - 1]; break;
    case 'PageDown': next = [Math.min(last, Math.max(0, row) + page), col]; break;
    case 'PageUp': next = [Math.max(0, row - page), col]; break;
    case ' ':
      if (row >= 0) { toggleRow(grid, row, e.shiftKey ? 'range' : 'toggle'); next = [row, col]; }
      break;
    case 'a':
    case 'A':
      // APG grid: Ctrl/Cmd+A selects every row of the query
      if (!(e.ctrlKey || e.metaKey) || grid.dataset.select !== 'multiple') return;
      selectAll(grid);
      next = [row, col];
      break;
    case 'Enter':
      if (row === -1) {
        const column = grid._order[col];
        if (column.sortable) query(grid, { sorters: cycleSort(configOf(grid).sorters, column.sortField, e.shiftKey), page: 0 });
        next = [-1, col];
      } else if (entry) {
        // Fires on Enter on a row - its record and its position in the query.
        grid.dispatchEvent(new CustomEvent<DataGridActivateDetail>('data-grid-activate', { bubbles: true, detail: { record: entry.row, index: row } }));
      }
      break;
    default:
      return;
  }
  e.preventDefault();
  if (next) focusCell(grid, next[0], next[1]);
}
function onClick(grid, e) {
  const t = e.target;
  const pin = t.closest('.data-grid-pin');
  if (pin) {
    const locked = new Set(configOf(grid).locked || []);
    if (locked.has(pin.dataset.field)) locked.delete(pin.dataset.field);
    else locked.add(pin.dataset.field);
    query(grid, { locked: grid._columns.map((c) => c.field).filter((f) => locked.has(f)) });
    return;
  }
  const pageButton = t.closest('[data-page]');
  if (pageButton && grid._parts.footer.contains(pageButton)) {
    const page = Math.max(0, configOf(grid).page || 0);
    const target = { first: 0, prev: page - 1, next: page + 1, last: pageCount(grid) - 1 }[pageButton.dataset.page];
    query(grid, { page: Math.max(0, Math.min(pageCount(grid) - 1, target)) });
    return;
  }
  const header = t.closest('.data-grid-header');
  if (header && grid.contains(header)) {
    const column = grid._columns.find((c) => c.el === header);
    if (column?.sortable) query(grid, { sorters: cycleSort(configOf(grid).sorters, column.sortField, e.shiftKey), page: 0 });
    grid._focus = { row: -1, col: grid._order.indexOf(column) };
    return;
  }
  const row = t.closest('.data-grid-row');
  if (!row || !grid._parts.pool.contains(row)) return;
  const index = row._index;
  const cell = t.closest('.data-grid-cell');
  grid._focus = { row: index, col: Math.max(0, [...row.children].indexOf(cell)) };
  if (t.closest('.data-grid-toggle')) return toggleExpand(grid, index);
  if (t.closest('a, button, input, select, textarea, label')) return;
  toggleRow(grid, index, e.shiftKey ? 'range' : e.ctrlKey || e.metaKey ? 'toggle' : 'only');
}
/** the filter row → config.filters (debounced while typing) */
function onFilterInput(grid, e) {
  const input = (e.target as HTMLElement).closest?.<HTMLInputElement>('.data-grid-filter');
  if (!input) return;
  clearTimeout(grid._filterTimer);
  grid._filterTimer = setTimeout(() => {
    const filters = dfDollar(grid._parts.filters).find<HTMLInputElement>('.data-grid-filter').toArray()
      .map((el) => parseFilter(el.dataset.field, el.value, el.dataset.kind as 'text' | 'number' | 'select'))
      .filter(Boolean);
    query(grid, { filters, page: 0, collapsed: [] });
  }, e.type === 'change' ? 0 : 200);
}
/** infinite: near the end, load the next page (from the rows, or the loader) */
async function onNearEnd(grid, force = false) {
  if (paging(grid) !== 'infinite' || grid._loadingMore || !grid._source) return;
  const config = configOf(grid);
  const loaded = grid._source.rows.length > 0;
  const loadedRows = ((config.page || 0) + 1) * pageSize(grid);
  const { viewport } = parts(grid);
  if (!force && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - rowHeight(grid) * 4) return;
  if (loadedRows < (grid._result?.entries.length ?? 0)) {
    query(grid, { page: (config.page || 0) + 1 });
    return;
  }
  if (!grid._load || grid._exhausted) return;
  grid._loadingMore = true;
  grid.setAttribute('data-loading-more', '');
  try {
    const more = await grid._load(grid._source.rows.length, pageSize(grid));
    if (!more?.length) grid._exhausted = true;
    else grid._source.setRows([...grid._source.rows, ...more]);
    // the first page arrives in 'loading'; every later one adds a page
    dataGridApi.setState(grid, 'default', loaded && more?.length ? { page: (config.page || 0) + 1 } : {});
  } finally {
    grid._loadingMore = false;
    grid.removeAttribute('data-loading-more');
  }
  // a short first page may not fill the viewport - keep going until it does
  if (!grid._exhausted && grid._parts.viewport.scrollHeight <= grid._parts.viewport.clientHeight) onNearEnd(grid, true);
}
// -- df$.shadcn.dataGrid: the imperative surface -------------------------------
const resolve = (target) => (typeof target === 'string' ? dfDollar(target).get(0) : target);
df$.dataGrid = {
  /**
   * Hand the grid its records. Options: idField ('id'), parentIdField (a
   * tree grid; or data-parent-field), cells ({ field: (el, record, meta) })
   * custom cell content, load(offset, size) → Promise<records[]> for
   * data-paging="infinite" from a remote source, query ({ sorters, filters,
   * locked, ... }) the starting query, persist ({ area: 'session' | 'local' |
   * 'none', prefix, key }) where the view is kept - a kept view wins over
   * the starting query.
   * @param target - the .data-grid element or its selector
   * @param rows - the records (a tree grid: a flat list linked by parent id)
   * @param options - fields, custom cells, a remote loader, the starting view and its persistence
   */
  setSource(target: string | HTMLElement, rows: DataviewRow[], options: DataGridOptions = {}): void {
    const grid = resolve(target);
    grid._sourceOptions = options;
    const parentIdField = options.parentIdField || grid.dataset.parentField;
    const idField = options.idField || grid.dataset.idField || 'id';
    grid._source = dataSource(rows, { idField, tree: parentIdField ? { idField, parentIdField } : undefined });
    grid._cells = options.cells || null;
    grid._load = options.load || null;
    grid._exhausted = false;
    grid.setAttribute('role', parentIdField ? 'treegrid' : 'grid');
    if (!grid.store) return;
    if (grid._parts.filters) grid._parts.filters._key = ''; // select options follow the data
    const patch = { ...options.query, ...(options.persist ? attachPersistence(grid, options.persist) : {}) };
    // a loader with nothing loaded yet: 'loading' until its first page lands
    if (grid._load && !rows.length) {
      dataGridApi.setState(grid, 'loading', { ...patch, page: 0 });
      onNearEnd(grid, true);
      return;
    }
    dataGridApi.setState(grid, 'default', patch);
  },
  /**
   * Merge into the query: { filters?, sorters?, locked?, page?, expanded?, selected? }.
   * @param target - the .data-grid element or its selector
   * @param patch - the query keys to change
   */
  query: (target: string | HTMLElement, patch: DataGridQuery): void => { query(resolve(target), patch); },
  /**
   * The records the query shows.
   * @param target - the .data-grid element or its selector
   * @returns the records in query order, every page
   */
  rows: (target: string | HTMLElement): DataviewRow[] => (resolve(target)._result?.entries ?? []).map((e) => e.row),
  /**
   * The selected records.
   * @param target - the .data-grid element or its selector
   * @returns the selected records, in source order
   */
  selected(target: string | HTMLElement): DataviewRow[] {
    const grid = resolve(target);
    const ids = grid._selected ?? new Set();
    return (grid._source?.rows ?? []).filter((r) => ids.has(r[grid._source.idField]));
  },
  /**
   * Select every row the query shows (data-select="multiple").
   * @param target - the .data-grid element or its selector
   */
  selectAll: (target: string | HTMLElement): void => selectAll(resolve(target)),
  /**
   * Select nothing.
   * @param target - the .data-grid element or its selector
   */
  clearSelection: (target: string | HTMLElement): void => { query(resolve(target), { selected: [] }); },
  /**
   * Tree grid: open every branch.
   * @param target - the .data-grid element or its selector
   */
  expandAll(target: string | HTMLElement): void {
    const grid = resolve(target);
    query(grid, { expanded: grid._source.branchIds(), collapsed: [] });
  },
  /**
   * Tree grid: close every branch (while filtering: the ways to the matches too).
   * @param target - the .data-grid element or its selector
   */
  collapseAll(target: string | HTMLElement): void {
    const grid = resolve(target);
    const filtering = (configOf(grid).filters || []).length > 0;
    query(grid, filtering ? { collapsed: grid._source.branchIds() } : { expanded: [] });
  },
};
// -- init --------------------------------------------------------------------------
function init() {
  dfDollar('.data-grid:not([data-init])').toArray().forEach((grid) => {
    grid.dataset.init = '';
    const viewport = dfDollar(grid).children('.data-grid-viewport').get(0);
    const head = viewport && dfDollar(viewport).children('.data-grid-head').get(0);
    const body = viewport && dfDollar(viewport).children('.data-grid-body').get(0);
    if (!viewport || !head || !body) return;
    let footer = dfDollar(grid).children('.data-grid-footer').get(0);
    if (!footer) {
      footer = document.createElement('div');
      footer.className = 'data-grid-footer';
      dfDollar(grid).append(footer);
    }
    // the empty message: CSS attr() reads the body's own attribute
    body.dataset.emptyText = grid.dataset.emptyText || 'No rows match.';
    const pool = document.createElement('div');
    pool.className = 'data-grid-rows';
    pool.setAttribute('role', 'presentation');
    dfDollar(body).append(pool);
    grid._parts = { viewport, head, body, pool, footer };
    grid._columns = readColumns(grid);
    grid._focus = { row: 0, col: 0 };
    grid._selected = new Set();
    grid._rowHeight = parseFloat(getComputedStyle(grid).getPropertyValue('--data-grid-row-height')) || 36;
    if (!grid.hasAttribute('role')) grid.setAttribute('role', grid.dataset.parentField ? 'treegrid' : 'grid');
    if (grid.dataset.select === 'multiple') grid.setAttribute('aria-multiselectable', 'true');
    for (const column of grid._columns) {
      column.el.setAttribute('role', 'columnheader');
      column.el.tabIndex = -1;
    }
    buildPins(grid);
    buildFilters(grid);
    // the authored query (data-locked / data-sort headers), then setSource's
    // starting query (records may arrive first), then the kept view
    const early = grid._sourceOptions || {};
    const config = {
      filters: [], sorters: authoredSort(grid), locked: grid._columns.filter((c) => c.el.hasAttribute('data-locked')).map((c) => c.field),
      page: 0, expanded: [], collapsed: [], selected: [],
      ...early.query,
      ...attachPersistence(grid, early.persist),
    };
    // one render per animation frame, however many scroll events arrive
    let queued = false;
    viewport.addEventListener('scroll', () => {
      if (queued) return;
      queued = true;
      requestAnimationFrame(() => {
        queued = false;
        renderRows(grid);
        onNearEnd(grid);
      });
    }, { passive: true });
    new ResizeObserver(() => {
      if (!grid._order) return;
      layoutColumns(grid, configOf(grid));
      grid._gen++;
      renderRows(grid);
    }).observe(grid);
    grid.addEventListener('click', (e) => onClick(grid, e));
    grid.addEventListener('keydown', (e) => onKeydown(grid, e));
    grid.addEventListener('input', (e) => onFilterInput(grid, e));
    grid.addEventListener('change', (e) => onFilterInput(grid, e));
    grid.addEventListener('focusin', (e) => {
      // the first Tab lands on the active cell
      if (e.target === grid) focusCell(grid, grid._focus.row, grid._focus.col);
    });
    // el.store + el.api (AGENTS.md "State through stores"): no records yet = loading
    bindComponent(grid, dataGridApi, { name: grid._source ? 'default' : 'loading', config });
    dataGridApi.setState(grid, grid._source && !(grid._load && !grid._source.rows.length) ? 'default' : 'loading', config);
    if (grid._load && !grid._source.rows.length) onNearEnd(grid, true);
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub