Theme
Design your own
On this page (6)

§111,110 nodes

Ten regions × ten countries × a hundred cities × ten districts - one flat array of records with a parentId. Open a branch with its chevron or → / ← on the first column. Filter 'district 4.2.17' to see a match with its whole path opened; sort Population (Shift+click Name to break ties). Expand all opens 11,110 branches at once.

§Paged tree

data-paging='pages' pages the VISIBLE rows: open a branch and the pages after it shift; a filter opens the paths to its matches and pages through them.

§Driven through the store

Open branches are config.expanded - plain JSON in the grid's store. The list below subscribes to it; the buttons only write it.

§How it works

The tree grid IS the Data Grid - same component, same files, same State API (see its page for every attribute and state). data-parent-field (and data-id-field, default id) switch on the hierarchy:

§CSS view file

The tree grid is the Data Grid: the same stylesheet - the first column's indentation and toggle are its .data-grid-toggle rules.

/* -- 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