Tree GridATM
A Data Grid whose rows may have a parent. Name the parent field with data-parent-field and the grid becomes an ARIA treegrid: the first column indents by depth and opens and closes branches, while everything a grid does still works on the visible rows - multisort orders siblings, a column filter keeps the ancestors of every match and opens the way to it, pages and infinite loading walk the tree, columns lock. The records stay one flat array; defuss-dataview builds the hierarchy.
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:
role="treegrid"; each row carriesaria-leveland, with children,aria-expanded.- The config gains
expanded(the open branch ids) and, while a filter is active,collapsed(branches closed again - a filter opens every branch that holds a match). - Sorting orders siblings; the hierarchy never breaks. A filter's matches keep their ancestors, and the status line counts both ("12 rows shown · 10 of 11,020 match").
- Keyboard on the first column: → opens a branch, ← closes it or moves to the parent.
df$.shadcn.dataGrid.expandAll(grid)/collapseAll(grid)open or close everything.
§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