Data GridATM
A table for data no table can hold. Only the rows on screen exist in the DOM, and every query runs locally over all of them through defuss-dataview: sort by several columns at once, filter each column, lock columns that stay put while the rest scroll, page through, or load more as you reach the end. The query is the grid's state - el.store - so any code can follow it, drive it or keep it. Give rows a parent and it is a tree grid.
On this page (11)
§100,000 orders
Click a header to sort, Shift+click to add a column to the sort. Type in the filter row - every filter runs over all 100,000 rows (text anywhere, case-insensitive; numbers take > 500, <= 20 …). The pin in a header locks the column: it moves into the sticky group and stays while the rest scrolls sideways. Click / Shift+click / Ctrl+click select rows; arrows move cell by cell. The line under the grid subscribes to the grid's store.
§Pages
data-paging='pages' shows data-page-size rows a page and a pager below; the page is config.page (0-based), so sorting or filtering starts again at page 1.
§Infinite scrolling
data-paging='infinite' adds a page as you near the end: first from the records it has, then from load(offset, size) - an async function (here a fake 400 ms network) that returns the next records, or none when there are no more. The grid waits in 'loading' for the first page.
§The store is the query
Everything the grid shows is el.store.value.config - plain JSON. These buttons only write the query (df$.shadcn.dataGrid.query merges into it); the panel below only subscribes to the store. The grid does the rest.
§Custom cells
cells: { field: (el, record) } fills a column with anything - a badge, a meter. Cells are recycled with their rows; the function gets a fresh .data-grid-content element each time.
§A kept view
Every grid keeps its sort, filters, locked columns and open rows - in session storage by default, under a generated key (data-persist-prefix sets its start, data-persist-key the whole key). data-persist='local' keeps them across visits, 'none' keeps nothing; setSource's persist option does the same from code. data-sort on a header is the starting sort. This sandboxed example has no storage (the store falls back to memory), so instead of reloading: sort, filter or lock a column, then mount a fresh grid - it comes back as you left it.
§Loading and empty
A grid without records waits in 'loading' (skeleton rows, aria-busy); a query nothing matches lands in 'empty' with data-empty-text.
§States
Named states via the shared State API, driven per instance through the bound api; the config of every state is the query (filters, sorters, locked, page, expanded, collapsed, selected), merged on each setState and observable as el.store:
default- the rows of the query; a query nothing matches lands inemptyloading-aria-busy="true"and skeleton rows; a grid without records starts hereempty- no rows: thedata-empty-textmessage
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/data-grid-{state}.png.
Machine contract - verified against data-grid.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
loading | boolean | true, false | false | aria-busy with skeleton rows (setState('loading')). |
empty | boolean | true, false | false | No rows - shows data-empty-text (setState('empty')). |
§API
Generated from data-grid.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type DataGridState = 'default' | 'loading' | 'empty' - setState(name, config) takes the config of the state it names.
| State | Description | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
default | The rows of the query; a query nothing matches lands in empty.
| ||||||||||||||||||||||||
loading | Busy: skeleton rows, aria-busy - a grid without records (or waiting for load()) starts here.
| ||||||||||||||||||||||||
empty | No rows: the data-empty-text shows.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends DataGridState>(name: S, config?: DataGridStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: DataGridState; config: DataGridStateConfigs[DataGridState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.dataGridApi.setState<S extends DataGridState>(el: HTMLElement, name: S, config?: DataGridStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.dataGridApi.getState(el: HTMLElement): { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.dataGridApi.render(state: { name: DataGridState; config: DataGridStateConfigs[DataGridState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.dataGridApi.store(el: HTMLElement): Store<{ name: DataGridState; config: DataGridStateConfigs[DataGridState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.dataGridApi.commit<S extends DataGridState>(el: HTMLElement, name: S, config?: DataGridStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.dataGridStates: DataGridState[] | The declared states, 'default' first: default, loading, empty. |
df$.shadcn.dataGrid
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
setSource(target: string | HTMLElement, rows: DataviewRow[], options: DataGridOptions = {}): void | Hand the grid its records. Options: idField ('id'), parentIdField (a tree grid; or data-parent-field), cells ({ field: (el, record, meta) }) custom cell content, load(offset, size) → Promise<records[]> for data-paging="infinite" from a remote source, query ({ sorters, filters, locked, ... }) the starting query, persist ({ area: 'session' | 'local' | 'none', prefix, key }) where the view is kept - a kept view wins over the starting query.
| ||||||||||||
query(target: string | HTMLElement, patch: DataGridQuery): void | Merge into the query: { filters?, sorters?, locked?, page?, expanded?, selected? }.
| ||||||||||||
rows(target: string | HTMLElement): DataviewRow[] | The records the query shows.
Returns | ||||||||||||
selected(target: string | HTMLElement): DataviewRow[] | The selected records.
Returns | ||||||||||||
selectAll(target: string | HTMLElement): void | Select every row the query shows (data-select="multiple").
| ||||||||||||
clearSelection(target: string | HTMLElement): void | Select nothing.
| ||||||||||||
expandAll(target: string | HTMLElement): void | Tree grid: open every branch.
| ||||||||||||
collapseAll(target: string | HTMLElement): void | Tree grid: close every branch (while filtering: the ways to the matches too).
|
Events
| Event | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
data-grid-activate | Fires on Enter on a row - its record and its position in the query.
|
Types
| Type | Description | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
DataGridActivateDetail | What data-grid-activate carries.
| ||||||||||||||||||||||||
DataGridOptions | What setSource() takes besides the records.
| ||||||||||||||||||||||||
DataGridQuery | The grid's query - its state config (merged on every query()).
| ||||||||||||||||||||||||
DataGridRowMeta | Where a record sits - what a custom cell receives.
|
§CSS view file
/* -- Data Grid --------------------------------------------------- *//* One scroll container holds a sticky head and the windowed body, *//* so the head and every row scroll sideways together and locked *//* columns stick with position: sticky - no JS scroll syncing. */@layer components { .data-grid { --data-grid-row-height: 36px; --data-grid-template: repeat(4, minmax(8rem, 1fr)); display: flex; flex-direction: column; min-height: 12rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--card); color: var(--card-foreground); font-size: 0.875rem; overflow: hidden; &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } } /* the one scroll container: both axes */ .data-grid-viewport { position: relative; flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; /* the windowing owns the scroll position: Firefox's scroll anchoring would follow the recycled rows, move scrollTop, re-render and move it again - a scroll that runs on by itself (big-data-firefox.e2e) */ overflow-anchor: none; scrollbar-gutter: stable; } .data-grid-head { position: sticky; top: 0; z-index: 2; width: max-content; min-width: 100%; background: var(--muted); color: var(--muted-foreground); border-bottom: 1px solid var(--border); } /* every row - head, filters, body - lays out the same tracks */ .data-grid-row { display: grid; grid-template-columns: var(--data-grid-template); width: max-content; min-width: 100%; } .data-grid-header, .data-grid-cell, .data-grid-filter-cell { display: flex; align-items: center; gap: 0.375rem; min-width: 0; padding-inline: 0.75rem; background: inherit; &[data-locked] { position: sticky; z-index: 1; } &[data-align="end"] { justify-content: flex-end; font-variant-numeric: tabular-nums; } } /* the last locked column draws the edge the rest scrolls under */ .data-grid[data-has-locked] :is(.data-grid-header, .data-grid-cell, .data-grid-filter-cell)[data-locked]:not(:has(~ [data-locked])) { box-shadow: 1px 0 0 var(--border), 6px 0 8px -6px color-mix(in oklch, var(--foreground) 25%, transparent); } .data-grid-header { height: var(--data-grid-row-height); font-weight: 500; font-size: 0.8125rem; background: var(--muted); cursor: pointer; user-select: none; white-space: nowrap; &[data-type="number"] { justify-content: flex-end; } &[data-sortable="false"] { cursor: default; } /* the sort direction, and the priority of a multisort (1, 2, …) */ &[aria-sort]::after { content: ""; flex: none; width: 0.5rem; height: 0.5rem; border-inline-end: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; rotate: 45deg; translate: 0 -0.125rem; color: var(--foreground); } &[aria-sort="ascending"]::after { rotate: -135deg; translate: 0 0.125rem; } &[data-sort-index]::before { content: attr(data-sort-index); order: 2; font-size: 0.6875rem; font-variant-numeric: tabular-nums; color: var(--foreground); } &[aria-sort] { color: var(--foreground); } &:hover { color: var(--foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } } /* the lock toggle: a pin that shows on hover / focus and stays when locked */ .data-grid-pin { order: 3; flex: none; display: grid; place-items: center; width: 1.375rem; height: 1.375rem; margin-inline-start: auto; padding: 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--muted-foreground); cursor: pointer; opacity: 0; transition: opacity 120ms ease; /* the icon is a mask over currentColor (pin), so it follows the theme */ &::before { content: ""; width: 0.875rem; height: 0.875rem; background: currentColor; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 17v5'/%3E%3Cpath d='M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z'/%3E%3C/svg%3E") center / contain no-repeat; rotate: 45deg; } &:hover { background: var(--accent); color: var(--accent-foreground); } } .data-grid-header:is(:hover, :focus-within, [data-locked]) .data-grid-pin { opacity: 1; } .data-grid-header[data-locked] .data-grid-pin { color: var(--primary); &::before { rotate: 0deg; } } .data-grid-header[data-type="number"] .data-grid-pin { order: -1; margin-inline: 0 auto; } /* -- the filter row ------------------------------------------ */ .data-grid-filters { border-top: 1px solid var(--border); background: var(--card); } .data-grid-filter-cell { height: calc(var(--data-grid-row-height) + 4px); padding-inline: 0.375rem; background: var(--card); } .data-grid-filter { width: 100%; min-width: 0; height: 1.75rem; padding-inline: 0.5rem; border: 1px solid var(--input); border-radius: var(--radius-sm); background: var(--background); color: var(--foreground); font: inherit; font-size: 0.8125rem; &::placeholder { color: var(--muted-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: -1px; border-color: var(--ring); } } /* -- the windowed body ----------------------------------------- */ .data-grid-body { position: relative; width: max-content; min-width: 100%; contain: layout paint; } .data-grid-rows { position: absolute; top: 0; inset-inline-start: 0; width: max-content; min-width: 100%; will-change: translate; } .data-grid-body .data-grid-row { height: var(--data-grid-row-height); background: var(--card); border-bottom: 1px solid var(--border); cursor: default; &:hover { background: color-mix(in oklch, var(--accent) 60%, var(--card)); } &[aria-selected="true"] { background: color-mix(in oklch, var(--primary) 12%, var(--card)); } } .data-grid-cell { white-space: nowrap; &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } } .data-grid-content { min-width: 0; overflow: hidden; text-overflow: ellipsis; } /* -- tree grid: the first column indents and carries the toggle --- */ .data-grid-toggle { flex: none; display: grid; place-items: center; width: 1.25rem; height: 1.25rem; margin-inline-start: calc(var(--depth, 0) * 1.25rem); border-radius: var(--radius-sm); color: var(--muted-foreground); cursor: pointer; &::before { content: ""; width: 0.375rem; height: 0.375rem; border-inline-end: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; rotate: -45deg; transition: rotate 150ms ease; } &[data-leaf] { cursor: default; &::before { content: none; } } &:not([data-leaf]):hover { background: var(--accent); color: var(--accent-foreground); } } .data-grid-row[aria-expanded="true"] .data-grid-toggle::before { rotate: 45deg; } /* -- footer: counts + pager --------------------------------------- */ .data-grid-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; padding: 0.5rem 0.75rem; border-top: 1px solid var(--border); color: var(--muted-foreground); font-size: 0.8125rem; font-variant-numeric: tabular-nums; &:empty { display: none; } } .data-grid-pager { display: flex; align-items: center; gap: 0.25rem; } .data-grid-page-label { padding-inline: 0.5rem; color: var(--foreground); } .data-grid-page-btn { display: grid; place-items: center; width: 1.75rem; height: 1.75rem; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--background); color: var(--foreground); cursor: pointer; /* chevrons as masks over currentColor */ &::before { content: ""; width: 1rem; height: 1rem; background: currentColor; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E") center / contain no-repeat; } &[data-page="first"]::before { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m11 17-5-5 5-5'/%3E%3Cpath d='m18 17-5-5 5-5'/%3E%3C/svg%3E"); } &[data-page="next"]::before { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E"); } &[data-page="last"]::before { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 17 5-5-5-5'/%3E%3Cpath d='m13 17 5-5-5-5'/%3E%3C/svg%3E"); } &:dir(rtl)::before { scale: -1 1; } &:hover:not(:disabled) { background: var(--accent); color: var(--accent-foreground); } &:disabled { opacity: 0.5; cursor: default; } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } } /* infinite: the next page is on its way */ .data-grid[data-loading-more] .data-grid-status::after { content: " · loading more…"; } /* -- loading and empty -------------------------------------------- */ .data-grid[data-state="loading"], .data-grid[data-state="empty"] { & .data-grid-rows { display: none; } & .data-grid-body { height: auto !important; /* nothing to scroll in these states */ min-height: calc(var(--data-grid-row-height) * 4); } } .data-grid[data-state="loading"] .data-grid-body { background: repeating-linear-gradient( to bottom, var(--muted) 0, var(--muted) calc(var(--data-grid-row-height) - 1px), transparent calc(var(--data-grid-row-height) - 1px), transparent var(--data-grid-row-height) ); opacity: 0.6; animation: data-grid-pulse 1.6s ease-in-out infinite; } /* attr() reads the body's own attribute: init copies the grid's data-empty-text onto it */ .data-grid[data-state="empty"] .data-grid-body::after { content: attr(data-empty-text); position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted-foreground); } @keyframes data-grid-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 0.3; } }}@media (prefers-reduced-motion: reduce) { @layer components { .data-grid, .data-grid *, .data-grid *::before, .data-grid *::after, .data-grid[data-state="loading"] .data-grid-body { transition: none; animation: none; scroll-behavior: auto; } }}@media (prefers-contrast: more) { @layer components { .data-grid, .data-grid-head, .data-grid-body .data-grid-row, .data-grid-filter, .data-grid-page-btn { border-color: var(--muted-foreground); } .data-grid-header, .data-grid-footer { color: var(--foreground); } }}@media (forced-colors: active) { @layer components { .data-grid, .data-grid-head, .data-grid-body .data-grid-row, .data-grid-filter, .data-grid-page-btn { border-color: ButtonText; } .data-grid-body .data-grid-row[aria-selected="true"] { background: Highlight; color: HighlightText; } .data-grid-cell:focus-visible, .data-grid-header:focus-visible { outline-color: Highlight; } .data-grid-header[aria-sort]::after, .data-grid-toggle::before { border-color: ButtonText; } /* masked icons paint their background - keep it the text color */ .data-grid-pin::before, .data-grid-page-btn::before { forced-color-adjust: none; background: ButtonText; } }}§JS view file
// -- Data Grid ------------------------------------------------// A grid over any number of records: only the rows on screen exist in the// DOM (shared windowing, src/shared/virtual.ts) and every query runs locally// over all rows through defuss-dataview (src/shared/dataview.ts) - multisort,// column filters, locked columns, pages, infinite loading, selection. With// data-parent-field every row may have a parent: the grid becomes an ARIA// treegrid that still filters, sorts and pages.//// The query IS the state's config (AGENTS.md "State through stores"):// el.store.value.config = { filters, sorters, locked, page, expanded,// collapsed, selected } - plain JSON any code can subscribe to or set. The// view (sort, filters, locked columns, open rows) survives a reload: session// storage by default, local storage or nothing per instance (data-persist,// data-persist-prefix / -key, or setSource's persist option).// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, textLocale, componentState, bindComponent, persisted, viewPersistence, dataSource, parseFilter, filterText, cycleSort, virtualWindow, sizerHeight, scrollIntoViewTop,} from '../../../shared/state-api.js';import type { DataviewFilter, DataviewJsonValue, DataviewRow, DataviewSorter } from '../../../shared/dataview.js';import type { ViewPersistence } from '../../../shared/store.js';const df$ = defussGlobals();const dfDollar = defussQuery();// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type// this file's API docs state - arguments, return values, event details - holds// against its code: a wrong one is a new type error and fails the build./** The grid's query - its state config (merged on every query()). */type DataGridQuery = { /** filters over every record (a tree grid shows a match with its ancestors) */ filters?: DataviewFilter[]; /** the sort order - several sorters sort by each in turn */ sorters?: DataviewSorter[]; /** the fields of the locked columns, pinned to the start in this order */ locked?: string[]; /** the page shown, 0-based (data-paging="pages") */ page?: number; /** tree grid: the ids of the open rows */ expanded?: DataviewJsonValue[]; /** tree grid, while filtering: the rows the user closed again */ collapsed?: DataviewJsonValue[]; /** the ids of the selected rows */ selected?: DataviewJsonValue[];};/** Where a record sits - what a custom cell receives. */interface DataGridRowMeta { /** tree grid: 0 for a root row */ depth: number; /** tree grid: whether it has child rows */ hasChildren: boolean; /** tree grid: whether its children show */ isExpanded: boolean; /** whether it matches the filters (false for an ancestor shown for a match) */ isMatch: boolean; /** whether it is selected */ isSelected: boolean; /** tree grid: its parent's id, null for a root */ parentId: DataviewJsonValue | null;}/** What setSource() takes besides the records. */interface DataGridOptions { /** the id field (default 'id', or data-id-field) */ idField?: string; /** a tree grid: the parent-id field (or data-parent-field) */ parentIdField?: string; /** custom cell content per field: fill the cell's content element */ cells?: Record<string, (el: HTMLElement, record: DataviewRow, meta: DataGridRowMeta) => void>; /** data-paging="infinite" from a remote source: the next records from offset (an empty answer ends the loading) */ load?: (offset: number, size: number) => Promise<DataviewRow[]>; /** the starting query (a kept view wins over it) */ query?: DataGridQuery; /** where the view is kept between visits (default: session storage under a generated key) */ persist?: ViewPersistence;}/** What data-grid-activate carries. */interface DataGridActivateDetail { /** the row's record */ record: DataviewRow; /** its position in the query (every page) */ index: number;}const dataGridStates = ['default', 'loading', 'empty'];/** setState() configs per state - the config IS the grid's query, merged into the stored one ({ page: 3 } keeps the filters). */export interface DataGridStateConfigs { /** The rows of the query; a query nothing matches lands in empty. */ default: DataGridQuery; /** Busy: skeleton rows, aria-busy - a grid without records (or waiting for load()) starts here. */ loading: DataGridQuery; /** No rows: the data-empty-text shows. */ empty: DataGridQuery;}/** the part that is persisted (selection and the open page are per visit) */const SAVED_KEYS = ['filters', 'sorters', 'locked', 'expanded'];const numberFormat = new Map();/** a cell value as text: data-format="number" | "currency:EUR" | "percent" | "date" - in the text's locale (the grid's nearest [lang], else 'en') */function format(value, spec, el) { if (value == null) return ''; if (!spec) return String(value); const locale = textLocale(el); if (spec === 'date') { const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleDateString(locale); } const key = `${locale}|${spec}`; if (!numberFormat.has(key)) { const [style, currency] = spec.split(':'); numberFormat.set( key, new Intl.NumberFormat(locale, style === 'currency' ? { style, currency: currency || 'USD' } : style === 'percent' ? { style, maximumFractionDigits: 1 } : {}), ); } return typeof value === 'number' ? numberFormat.get(key).format(value) : String(value);}/** the header row: the first row of the head */const headerRowOf = (root) => dfDollar(root).find('.data-grid-head > .data-grid-row').get(0);/** the authored column headers of a grid (or of a detached copy), in DOM order */const headersOf = (root) => { const row = headerRowOf(root); return row ? (dfDollar(row).children('.data-grid-header').toArray()) : [];};/** The columns, read once from the authored headers (authored order). */function readColumns(grid) { return headersOf(grid).map((el) => ({ el, field: el.dataset.field, filter: el.dataset.filter || '', type: el.dataset.type === 'number' ? 'number' : 'text', // data-format="" keeps the raw value (an id needs no thousands separator) format: el.hasAttribute('data-format') ? el.dataset.format : el.dataset.type === 'number' ? 'number' : '', width: el.dataset.width || 'minmax(8rem, 1fr)', align: el.dataset.align || (el.dataset.type === 'number' ? 'end' : ''), sortable: el.dataset.sortable !== 'false', // sort by another field than the one shown (a priority's rank, not its label) sortField: el.dataset.sortField || el.dataset.field, options: el.dataset.options ? el.dataset.options.split(',').map((o) => o.trim()) : null, }));}/** * The persisted view: where (viewPersistence: data-persist, -prefix, -key, * or the config) and what was kept there. Re-attaching (setSource with a * persist option) moves the instance to the new place. */function attachPersistence(grid, config) { grid._saved?.destroy(); const where = viewPersistence(grid, 'data-grid', String(dfDollar('.data-grid').toArray().indexOf(grid)), config || {}); grid._saved = where ? persisted<Record<string, unknown>>(where.key, {}, { area: where.area, validate: (v): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v) }) : null; const kept = {}; for (const k of SAVED_KEYS) if (Array.isArray(grid._saved?.value[k])) kept[k] = grid._saved.value[k]; return kept;}/** the authored sort: data-sort="asc|desc" on headers, in column order */const authoredSort = (grid) => grid._columns.filter((c) => c.el.dataset.sort === 'asc' || c.el.dataset.sort === 'desc').map((c) => ({ field: c.sortField, direction: c.el.dataset.sort }));/** locked fields first (in authored order), then the rest */function ordered(items, locked, fieldOf) { const set = new Set(locked || []); return [...items.filter((i) => set.has(fieldOf(i))), ...items.filter((i) => !set.has(fieldOf(i)))];}/** * The markup of a state, for render() AND the live element (one function): * header order (locked columns first), data-locked, aria-sort and the sort * priority. 'default' with the authored config IS the authored markup. */function applyMarkup(root, state) { const config = state.config || {}; const headers = root._columns ? root._columns.map((c) => c.el) : headersOf(root); const row = headerRowOf(root); const locked = new Set(config.locked || []); const sorters = config.sorters || []; const order = ordered(headers, config.locked, (el) => el.dataset.field); // only move what is out of place: an authored order stays untouched if (row && order.some((el, i) => dfDollar(row).children('.data-grid-header').get(i) !== el)) { order.forEach((el) => dfDollar(row).append(el)); } for (const el of headers) { const at = sorters.findIndex((s) => s.field === (el.dataset.sortField || el.dataset.field)); const dir = at < 0 ? null : (sorters[at].direction || sorters[at].dir || 'asc'); dfDollar(el) .attr('data-locked', locked.has(el.dataset.field) ? '' : null) .attr('aria-sort', dir ? (dir === 'asc' ? 'ascending' : 'descending') : null) .attr('data-sort-index', dir && sorters.length > 1 ? String(at + 1) : null); } dfDollar(root) .attr('data-state', state.name) .attr('aria-busy', state.name === 'loading' ? 'true' : null);}// -- the query --------------------------------------------------------------// the config being shown: set first thing in apply (the store records it// only AFTER the DOM work - reading the store there would be one step behind)const configOf = (grid) => grid._config ?? grid.store?.value.config ?? {};const isTree = (grid) => !!grid._source?.tree;const paging = (grid) => grid.dataset.paging || 'virtual';const pageSize = (grid) => Math.max(1, parseInt(grid.dataset.pageSize || '50', 10) || 50);/** evaluate the config's query (cached per query inside the source) */function evaluate(grid, config) { if (!grid._source) return (grid._result = { entries: [], totalRows: 0, matchedRows: 0, visibleRows: 0 }); return (grid._result = grid._source.query({ filters: config.filters, sorters: config.sorters, expanded: config.expanded, collapsed: config.collapsed, }));}/** the entries the body shows: everything (virtual), one page, or the pages loaded */function shown(grid, config) { const entries = grid._result?.entries ?? []; const mode = paging(grid); if (mode === 'virtual') return entries; const size = pageSize(grid); const page = Math.max(0, config.page || 0); return mode === 'pages' ? entries.slice(page * size, (page + 1) * size) : entries.slice(0, (page + 1) * size);}const pageCount = (grid) => Math.max(1, Math.ceil((grid._result?.entries.length ?? 0) / pageSize(grid)));// -- layout -----------------------------------------------------------------const parts = (grid) => grid._parts;const rowHeight = (grid) => grid._rowHeight;/** column template + the sticky offsets of the locked columns */function layoutColumns(grid, config) { const columns = ordered(grid._columns, config.locked, (c) => c.field); grid._order = columns; grid.style.setProperty('--data-grid-template', columns.map((c) => c.width).join(' ')); const locked = new Set(config.locked || []); // offsets are measured: widths may be rem, minmax(), fr let left = 0; grid._lockLeft = {}; for (const column of columns) { if (!locked.has(column.field)) { column.el.style.removeProperty('inset-inline-start'); continue; } grid._lockLeft[column.field] = left; column.el.style.insetInlineStart = `${left}px`; left += column.el.getBoundingClientRect().width; } grid.toggleAttribute('data-has-locked', locked.size > 0);}/** the filter row (columns with data-filter): built once, kept in step */function buildFilters(grid) { if (!grid._columns.some((c) => c.filter) || grid._parts.filters) return; const row = document.createElement('div'); row.className = 'data-grid-row data-grid-filters'; row.setAttribute('role', 'row'); grid._parts.filters = row; dfDollar(grid._parts.head).append(row);}function syncFilters(grid, config) { const row = grid._parts.filters; if (!row) return; const filters = config.filters || []; const locked = new Set(config.locked || []); // rebuilt only when the column order changed; values are properties const key = grid._order.map((c) => c.field).join('|'); if (row._key !== key) { row._key = key; row.textContent = ''; for (const column of grid._order) { const cell = document.createElement('div'); cell.className = 'data-grid-filter-cell'; cell.setAttribute('role', 'gridcell'); cell.dataset.field = column.field; if (column.filter) { const label = `Filter ${column.el.textContent.trim()}`; let input; if (column.filter === 'select') { input = document.createElement('select'); const options = column.options || distinct(grid, column.field); const all = document.createElement('option'); all.value = ''; all.textContent = 'All'; input.append(all, ...options.map((o) => Object.assign(document.createElement('option'), { value: o, textContent: o }))); } else { input = document.createElement('input'); input.type = 'search'; input.placeholder = column.type === 'number' ? '> 100' : 'Filter…'; input.inputMode = column.type === 'number' ? 'decimal' : 'search'; input.autocomplete = 'off'; } input.className = 'data-grid-filter'; input.dataset.field = column.field; input.dataset.kind = column.filter === 'select' ? 'select' : column.type; input.setAttribute('aria-label', label); cell.append(input); } row.append(cell); } } for (const cell of dfDollar(row).children('.data-grid-filter-cell').toArray()) { const field = cell.dataset.field; if (locked.has(field)) cell.style.insetInlineStart = `${grid._lockLeft[field] ?? 0}px`; else cell.style.removeProperty('inset-inline-start'); cell.toggleAttribute('data-locked', locked.has(field)); const input = cell.firstElementChild as HTMLInputElement | null; // never under the cursor: the person is typing in it if (input && input !== document.activeElement) input.value = filterText(filters.find((f) => f.field === field)); }}/** the values a select filter offers (first 100 distinct, sorted) */function distinct(grid, field) { const seen = new Set(); for (const row of grid._source?.rows ?? []) { if (row[field] != null) seen.add(String(row[field])); if (seen.size >= 100) break; } return [...seen].sort();}/** a pin button per header: lock / unlock the column */function buildPins(grid) { for (const column of grid._columns) { if (column.el.dataset.lockable === 'false' || dfDollar(column.el).children('.data-grid-pin').get(0)) continue; const pin = document.createElement('button'); pin.type = 'button'; pin.className = 'data-grid-pin'; pin.tabIndex = -1; pin.dataset.field = column.field; pin.setAttribute('aria-label', `Lock ${column.el.textContent.trim()}`); dfDollar(column.el).append(pin); }}// -- rendering --------------------------------------------------------------/** fill one body cell */function fillCell(grid, cell, column, entry, first) { const record = entry.row; cell.dataset.field = column.field; cell.toggleAttribute('data-locked', column.field in grid._lockLeft); if (column.field in grid._lockLeft) cell.style.insetInlineStart = `${grid._lockLeft[column.field]}px`; else cell.style.removeProperty('inset-inline-start'); if (column.align) cell.dataset.align = column.align; else delete cell.dataset.align; cell.textContent = ''; if (first && isTree(grid)) { cell.style.setProperty('--depth', String(entry.meta.depth)); const toggle = document.createElement('span'); toggle.className = 'data-grid-toggle'; toggle.setAttribute('aria-hidden', 'true'); if (!entry.meta.hasChildren) toggle.dataset.leaf = ''; cell.append(toggle); } else { cell.style.removeProperty('--depth'); } const custom = grid._cells?.[column.field]; if (custom) { const host = document.createElement('span'); host.className = 'data-grid-content'; custom(host, record, entry.meta); cell.append(host); } else { const text = document.createElement('span'); text.className = 'data-grid-content'; text.textContent = format(record[column.field], column.format, grid); cell.append(text); }}/** writes the window of rows that belongs at the current scroll position */function renderRows(grid) { const { viewport, body, pool } = parts(grid); if (!pool || !grid._order) return; const config = configOf(grid); const entries = grid._shown || []; const total = entries.length; const headH = grid._parts.head.offsetHeight; const view = Math.max(rowHeight(grid), viewport.clientHeight - headH); const win = virtualWindow(Math.max(0, viewport.scrollTop), view, rowHeight(grid), total); body.style.height = `${sizerHeight(total, rowHeight(grid))}px`; while (pool.children.length < win.count) { const row = document.createElement('div'); row.className = 'data-grid-row'; row.setAttribute('role', 'row'); pool.append(row); } while (pool.children.length > win.count) pool.lastElementChild.remove(); pool.style.translate = `0 ${win.shift}px`; const selected = grid._selected; const base = paging(grid) === 'pages' ? Math.max(0, config.page || 0) * pageSize(grid) : 0; const headRows = grid._parts.filters ? 2 : 1; const focus = grid._focus; for (let i = 0; i < pool.children.length; i++) { const row = pool.children[i]; const index = win.first + i; const entry = entries[index]; const key = `${grid._gen}:${index}`; if (row._key !== key) { row._key = key; row._index = index; row.dataset.index = String(index); row.setAttribute('aria-rowindex', String(base + index + headRows + 1)); while (row.children.length < grid._order.length) { const cell = document.createElement('div'); cell.className = 'data-grid-cell'; cell.setAttribute('role', 'gridcell'); row.append(cell); } while (row.children.length > grid._order.length) row.lastElementChild.remove(); grid._order.forEach((column, c) => { fillCell(grid, row.children[c], column, entry, c === 0); row.children[c].setAttribute('aria-colindex', String(c + 1)); }); if (isTree(grid)) { row.setAttribute('aria-level', String(entry.meta.depth + 1)); if (entry.meta.hasChildren) row.setAttribute('aria-expanded', String(entry.meta.isExpanded)); else row.removeAttribute('aria-expanded'); } } const id = entry.row[grid._source.idField]; if (grid.dataset.select) row.setAttribute('aria-selected', String(selected.has(id))); else row.removeAttribute('aria-selected'); for (let c = 0; c < row.children.length; c++) { row.children[c].tabIndex = focus.row === index && focus.col === c ? 0 : -1; } }}/** the counts and the pager below the rows */function renderFooter(grid, config) { const count = (n) => format(n, 'number', grid); const footer = grid._parts.footer; if (!footer) return; const result = grid._result; const shownRows = grid._shown?.length ?? 0; const visible = result.entries.length; const filtered = (config.filters || []).length > 0; const mode = paging(grid); let text; if (isTree(grid)) { text = `${count(visible)} rows shown`; text += filtered ? ` · ${count(result.matchedRows)} of ${count(result.totalRows)} match` : ` of ${count(result.totalRows)}`; } else { text = filtered ? `${count(visible)} of ${count(result.totalRows)} rows match` : `${count(visible)} rows`; } if (mode === 'pages' && visible) { const start = Math.max(0, config.page || 0) * pageSize(grid); text = `${count(start + 1)}–${count(start + shownRows)} of ${text}`; } if (mode === 'infinite' && visible) text = `${count(shownRows)} loaded · ${text}`; if (grid._selected.size) text += ` · ${count(grid._selected.size)} selected`; if (!grid._parts.status) { const status = document.createElement('span'); status.className = 'data-grid-status'; status.setAttribute('role', 'status'); footer.append(status); grid._parts.status = status; } grid._parts.status.textContent = text; if (mode !== 'pages') return; if (!grid._parts.pager) { const pager = document.createElement('div'); pager.className = 'data-grid-pager'; for (const [act, name] of [['first', 'First page'], ['prev', 'Previous page'], ['label'], ['next', 'Next page'], ['last', 'Last page']]) { if (act === 'label') { const label = document.createElement('span'); label.className = 'data-grid-page-label'; pager.append(label); continue; } const button = document.createElement('button'); button.type = 'button'; button.className = 'data-grid-page-btn'; button.dataset.page = act; button.setAttribute('aria-label', name); pager.append(button); } footer.append(pager); grid._parts.pager = pager; } const page = Math.max(0, config.page || 0); const pages = pageCount(grid); dfDollar(grid._parts.pager).find('.data-grid-page-label').get(0).textContent = `Page ${count(page + 1)} of ${count(pages)}`; for (const button of dfDollar(grid._parts.pager).find<HTMLButtonElement>('[data-page]').toArray()) { const back = button.dataset.page === 'first' || button.dataset.page === 'prev'; button.disabled = back ? page <= 0 : page >= pages - 1; }}/** * The DOM side of a config: evaluate the query, lay out the columns, fill * the filter row, render the window and the footer. Returns the row count. */function refresh(grid, config, previous) { grid._config = config; evaluate(grid, config); grid._selected = new Set(config.selected || []); grid._shown = shown(grid, config); layoutColumns(grid, config); syncFilters(grid, config); grid._gen = (grid._gen || 0) + 1; const prev = previous?.config || {}; // a new query starts at the top const moved = ['filters', 'sorters', 'page'].some((k) => JSON.stringify(prev[k] ?? null) !== JSON.stringify(config[k] ?? null)); if (moved && paging(grid) !== 'infinite') grid._parts.viewport.scrollTop = 0; if (moved) grid._focus = { row: grid._focus.row < 0 ? -1 : 0, col: grid._focus.col }; grid.setAttribute('aria-rowcount', String((grid._result.entries.length || 0) + (grid._parts.filters ? 2 : 1))); grid.setAttribute('aria-colcount', String(grid._columns.length)); renderRows(grid); renderFooter(grid, config); return grid._result.entries.length;}/** * UI side of setState: 'default' shows the rows of the config's query (none * match → it lands in 'empty'), 'loading' marks the grid busy (skeleton * rows), 'empty' shows data-empty-text. Every state keeps the query. */function triggerStateChange(grid, state, previous) { grid._config = state.config; let name = state.name; if (name !== 'loading' && grid._parts) { const rows = refresh(grid, state.config, previous); if (name === 'default' && !rows) name = 'empty'; } applyMarkup(grid, { name, config: state.config }); grid.dataset.stateName = name; if (grid._saved) { const keep = {}; for (const k of SAVED_KEYS) if (state.config[k] !== undefined) keep[k] = state.config[k]; grid._saved.set(keep); }}/** Registry-level API; pass the grid explicitly. Unknown names throw. */export const dataGridApi = componentState({ component: 'data-grid', states: dataGridStates, // a config merges: { page: 3 } keeps the filters mergeConfig: true, apply: (grid, state, previous) => triggerStateChange(grid, state, previous), markup: (el, state) => applyMarkup(el, state),});df$.dataGridApi = dataGridApi;df$.dataGridStates = dataGridStates;// -- interaction --------------------------------------------------------------/** merge into the query (the store applies it) */const query = (grid, patch) => dataGridApi.setState(grid, grid.store.value.name === 'loading' ? 'loading' : 'default', patch);function toggleRow(grid, index, mode) { const entry = grid._shown[index]; if (!entry || !grid.dataset.select) return; const id = entry.row[grid._source.idField]; let next; if (grid.dataset.select === 'single' || mode === 'only') next = grid._selected.has(id) && grid._selected.size === 1 && mode !== 'only' ? [] : [id]; else if (mode === 'range' && grid._anchor != null) { const from = Math.min(grid._anchor, index); const to = Math.max(grid._anchor, index); next = [...new Set([...grid._selected, ...grid._shown.slice(from, to + 1).map((e) => e.row[grid._source.idField])])]; } else { next = grid._selected.has(id) ? [...grid._selected].filter((x) => x !== id) : [...grid._selected, id]; } if (mode !== 'range') grid._anchor = index; query(grid, { selected: next });}/** every row the query shows (all pages) */function selectAll(grid) { const idField = grid._source?.idField ?? 'id'; query(grid, { selected: (grid._result?.entries ?? []).map((e) => e.row[idField]) });}function toggleExpand(grid, index, open?: boolean) { const entry = grid._shown[index]; if (!entry?.meta.hasChildren) return; const id = entry.row[grid._source.idField]; const want = open ?? !entry.meta.isExpanded; if (want === entry.meta.isExpanded) return; const config = configOf(grid); if ((config.filters || []).length) { // while filtering every branch with a match is open; closing one is an exception const collapsed = new Set(config.collapsed || []); if (want) collapsed.delete(id); else collapsed.add(id); query(grid, { collapsed: [...collapsed] }); } else { const expanded = new Set(config.expanded || []); if (want) expanded.add(id); else expanded.delete(id); query(grid, { expanded: [...expanded] }); }}/** move the active cell, keep it on screen, focus it */function focusCell(grid, row, col) { const total = grid._shown.length; row = Math.max(-1, Math.min(total - 1, row)); col = Math.max(0, Math.min(grid._order.length - 1, col)); grid._focus = { row, col }; const { viewport } = parts(grid); if (row >= 0) { const view = viewport.clientHeight - grid._parts.head.offsetHeight; viewport.scrollTop = scrollIntoViewTop(row, viewport.scrollTop, view, rowHeight(grid), total); } renderRows(grid); for (const [c, header] of grid._order.entries()) header.el.tabIndex = row === -1 && c === col ? 0 : -1; const target = row === -1 ? grid._order[col].el : dfDollar(grid._parts.pool).children('.data-grid-row').toArray().find((r) => r._index === row)?.children[col]; target?.focus({ preventScroll: row === -1 });}function onKeydown(grid, e) { if (e.target.closest?.('.data-grid-filters, .data-grid-footer')) return; const { row, col } = grid._focus; const page = Math.max(1, Math.floor((grid._parts.viewport.clientHeight - grid._parts.head.offsetHeight) / rowHeight(grid)) - 1); const last = grid._shown.length - 1; const entry = row >= 0 ? grid._shown[row] : null; let next = null; switch (e.key) { case 'ArrowDown': next = [Math.min(last, row + 1), col]; break; case 'ArrowUp': next = [Math.max(-1, row - 1), col]; break; case 'ArrowRight': if (isTree(grid) && entry && col === 0 && entry.meta.hasChildren && !entry.meta.isExpanded) { toggleExpand(grid, row, true); next = [row, 0]; } else next = [row, col + 1]; break; case 'ArrowLeft': if (isTree(grid) && entry && col === 0) { if (entry.meta.hasChildren && entry.meta.isExpanded) { toggleExpand(grid, row, false); next = [row, 0]; } else if (entry.meta.parentId != null) { const parent = grid._shown.findIndex((x) => x.row[grid._source.idField] === entry.meta.parentId); next = [parent >= 0 ? parent : row, 0]; } else next = [row, 0]; } else next = [row, col - 1]; break; case 'Home': next = e.ctrlKey || e.metaKey ? [Math.min(row, 0), col] : [row, 0]; break; case 'End': next = e.ctrlKey || e.metaKey ? [last, col] : [row, grid._order.length - 1]; break; case 'PageDown': next = [Math.min(last, Math.max(0, row) + page), col]; break; case 'PageUp': next = [Math.max(0, row - page), col]; break; case ' ': if (row >= 0) { toggleRow(grid, row, e.shiftKey ? 'range' : 'toggle'); next = [row, col]; } break; case 'a': case 'A': // APG grid: Ctrl/Cmd+A selects every row of the query if (!(e.ctrlKey || e.metaKey) || grid.dataset.select !== 'multiple') return; selectAll(grid); next = [row, col]; break; case 'Enter': if (row === -1) { const column = grid._order[col]; if (column.sortable) query(grid, { sorters: cycleSort(configOf(grid).sorters, column.sortField, e.shiftKey), page: 0 }); next = [-1, col]; } else if (entry) { // Fires on Enter on a row - its record and its position in the query. grid.dispatchEvent(new CustomEvent<DataGridActivateDetail>('data-grid-activate', { bubbles: true, detail: { record: entry.row, index: row } })); } break; default: return; } e.preventDefault(); if (next) focusCell(grid, next[0], next[1]);}function onClick(grid, e) { const t = e.target; const pin = t.closest('.data-grid-pin'); if (pin) { const locked = new Set(configOf(grid).locked || []); if (locked.has(pin.dataset.field)) locked.delete(pin.dataset.field); else locked.add(pin.dataset.field); query(grid, { locked: grid._columns.map((c) => c.field).filter((f) => locked.has(f)) }); return; } const pageButton = t.closest('[data-page]'); if (pageButton && grid._parts.footer.contains(pageButton)) { const page = Math.max(0, configOf(grid).page || 0); const target = { first: 0, prev: page - 1, next: page + 1, last: pageCount(grid) - 1 }[pageButton.dataset.page]; query(grid, { page: Math.max(0, Math.min(pageCount(grid) - 1, target)) }); return; } const header = t.closest('.data-grid-header'); if (header && grid.contains(header)) { const column = grid._columns.find((c) => c.el === header); if (column?.sortable) query(grid, { sorters: cycleSort(configOf(grid).sorters, column.sortField, e.shiftKey), page: 0 }); grid._focus = { row: -1, col: grid._order.indexOf(column) }; return; } const row = t.closest('.data-grid-row'); if (!row || !grid._parts.pool.contains(row)) return; const index = row._index; const cell = t.closest('.data-grid-cell'); grid._focus = { row: index, col: Math.max(0, [...row.children].indexOf(cell)) }; if (t.closest('.data-grid-toggle')) return toggleExpand(grid, index); if (t.closest('a, button, input, select, textarea, label')) return; toggleRow(grid, index, e.shiftKey ? 'range' : e.ctrlKey || e.metaKey ? 'toggle' : 'only');}/** the filter row → config.filters (debounced while typing) */function onFilterInput(grid, e) { const input = (e.target as HTMLElement).closest?.<HTMLInputElement>('.data-grid-filter'); if (!input) return; clearTimeout(grid._filterTimer); grid._filterTimer = setTimeout(() => { const filters = dfDollar(grid._parts.filters).find<HTMLInputElement>('.data-grid-filter').toArray() .map((el) => parseFilter(el.dataset.field, el.value, el.dataset.kind as 'text' | 'number' | 'select')) .filter(Boolean); query(grid, { filters, page: 0, collapsed: [] }); }, e.type === 'change' ? 0 : 200);}/** infinite: near the end, load the next page (from the rows, or the loader) */async function onNearEnd(grid, force = false) { if (paging(grid) !== 'infinite' || grid._loadingMore || !grid._source) return; const config = configOf(grid); const loaded = grid._source.rows.length > 0; const loadedRows = ((config.page || 0) + 1) * pageSize(grid); const { viewport } = parts(grid); if (!force && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - rowHeight(grid) * 4) return; if (loadedRows < (grid._result?.entries.length ?? 0)) { query(grid, { page: (config.page || 0) + 1 }); return; } if (!grid._load || grid._exhausted) return; grid._loadingMore = true; grid.setAttribute('data-loading-more', ''); try { const more = await grid._load(grid._source.rows.length, pageSize(grid)); if (!more?.length) grid._exhausted = true; else grid._source.setRows([...grid._source.rows, ...more]); // the first page arrives in 'loading'; every later one adds a page dataGridApi.setState(grid, 'default', loaded && more?.length ? { page: (config.page || 0) + 1 } : {}); } finally { grid._loadingMore = false; grid.removeAttribute('data-loading-more'); } // a short first page may not fill the viewport - keep going until it does if (!grid._exhausted && grid._parts.viewport.scrollHeight <= grid._parts.viewport.clientHeight) onNearEnd(grid, true);}// -- df$.shadcn.dataGrid: the imperative surface -------------------------------const resolve = (target) => (typeof target === 'string' ? dfDollar(target).get(0) : target);df$.dataGrid = { /** * Hand the grid its records. Options: idField ('id'), parentIdField (a * tree grid; or data-parent-field), cells ({ field: (el, record, meta) }) * custom cell content, load(offset, size) → Promise<records[]> for * data-paging="infinite" from a remote source, query ({ sorters, filters, * locked, ... }) the starting query, persist ({ area: 'session' | 'local' | * 'none', prefix, key }) where the view is kept - a kept view wins over * the starting query. * @param target - the .data-grid element or its selector * @param rows - the records (a tree grid: a flat list linked by parent id) * @param options - fields, custom cells, a remote loader, the starting view and its persistence */ setSource(target: string | HTMLElement, rows: DataviewRow[], options: DataGridOptions = {}): void { const grid = resolve(target); grid._sourceOptions = options; const parentIdField = options.parentIdField || grid.dataset.parentField; const idField = options.idField || grid.dataset.idField || 'id'; grid._source = dataSource(rows, { idField, tree: parentIdField ? { idField, parentIdField } : undefined }); grid._cells = options.cells || null; grid._load = options.load || null; grid._exhausted = false; grid.setAttribute('role', parentIdField ? 'treegrid' : 'grid'); if (!grid.store) return; if (grid._parts.filters) grid._parts.filters._key = ''; // select options follow the data const patch = { ...options.query, ...(options.persist ? attachPersistence(grid, options.persist) : {}) }; // a loader with nothing loaded yet: 'loading' until its first page lands if (grid._load && !rows.length) { dataGridApi.setState(grid, 'loading', { ...patch, page: 0 }); onNearEnd(grid, true); return; } dataGridApi.setState(grid, 'default', patch); }, /** * Merge into the query: { filters?, sorters?, locked?, page?, expanded?, selected? }. * @param target - the .data-grid element or its selector * @param patch - the query keys to change */ query: (target: string | HTMLElement, patch: DataGridQuery): void => { query(resolve(target), patch); }, /** * The records the query shows. * @param target - the .data-grid element or its selector * @returns the records in query order, every page */ rows: (target: string | HTMLElement): DataviewRow[] => (resolve(target)._result?.entries ?? []).map((e) => e.row), /** * The selected records. * @param target - the .data-grid element or its selector * @returns the selected records, in source order */ selected(target: string | HTMLElement): DataviewRow[] { const grid = resolve(target); const ids = grid._selected ?? new Set(); return (grid._source?.rows ?? []).filter((r) => ids.has(r[grid._source.idField])); }, /** * Select every row the query shows (data-select="multiple"). * @param target - the .data-grid element or its selector */ selectAll: (target: string | HTMLElement): void => selectAll(resolve(target)), /** * Select nothing. * @param target - the .data-grid element or its selector */ clearSelection: (target: string | HTMLElement): void => { query(resolve(target), { selected: [] }); }, /** * Tree grid: open every branch. * @param target - the .data-grid element or its selector */ expandAll(target: string | HTMLElement): void { const grid = resolve(target); query(grid, { expanded: grid._source.branchIds(), collapsed: [] }); }, /** * Tree grid: close every branch (while filtering: the ways to the matches too). * @param target - the .data-grid element or its selector */ collapseAll(target: string | HTMLElement): void { const grid = resolve(target); const filtering = (configOf(grid).filters || []).length > 0; query(grid, filtering ? { collapsed: grid._source.branchIds() } : { expanded: [] }); },};// -- init --------------------------------------------------------------------------function init() { dfDollar('.data-grid:not([data-init])').toArray().forEach((grid) => { grid.dataset.init = ''; const viewport = dfDollar(grid).children('.data-grid-viewport').get(0); const head = viewport && dfDollar(viewport).children('.data-grid-head').get(0); const body = viewport && dfDollar(viewport).children('.data-grid-body').get(0); if (!viewport || !head || !body) return; let footer = dfDollar(grid).children('.data-grid-footer').get(0); if (!footer) { footer = document.createElement('div'); footer.className = 'data-grid-footer'; dfDollar(grid).append(footer); } // the empty message: CSS attr() reads the body's own attribute body.dataset.emptyText = grid.dataset.emptyText || 'No rows match.'; const pool = document.createElement('div'); pool.className = 'data-grid-rows'; pool.setAttribute('role', 'presentation'); dfDollar(body).append(pool); grid._parts = { viewport, head, body, pool, footer }; grid._columns = readColumns(grid); grid._focus = { row: 0, col: 0 }; grid._selected = new Set(); grid._rowHeight = parseFloat(getComputedStyle(grid).getPropertyValue('--data-grid-row-height')) || 36; if (!grid.hasAttribute('role')) grid.setAttribute('role', grid.dataset.parentField ? 'treegrid' : 'grid'); if (grid.dataset.select === 'multiple') grid.setAttribute('aria-multiselectable', 'true'); for (const column of grid._columns) { column.el.setAttribute('role', 'columnheader'); column.el.tabIndex = -1; } buildPins(grid); buildFilters(grid); // the authored query (data-locked / data-sort headers), then setSource's // starting query (records may arrive first), then the kept view const early = grid._sourceOptions || {}; const config = { filters: [], sorters: authoredSort(grid), locked: grid._columns.filter((c) => c.el.hasAttribute('data-locked')).map((c) => c.field), page: 0, expanded: [], collapsed: [], selected: [], ...early.query, ...attachPersistence(grid, early.persist), }; // one render per animation frame, however many scroll events arrive let queued = false; viewport.addEventListener('scroll', () => { if (queued) return; queued = true; requestAnimationFrame(() => { queued = false; renderRows(grid); onNearEnd(grid); }); }, { passive: true }); new ResizeObserver(() => { if (!grid._order) return; layoutColumns(grid, configOf(grid)); grid._gen++; renderRows(grid); }).observe(grid); grid.addEventListener('click', (e) => onClick(grid, e)); grid.addEventListener('keydown', (e) => onKeydown(grid, e)); grid.addEventListener('input', (e) => onFilterInput(grid, e)); grid.addEventListener('change', (e) => onFilterInput(grid, e)); grid.addEventListener('focusin', (e) => { // the first Tab lands on the active cell if (e.target === grid) focusCell(grid, grid._focus.row, grid._focus.col); }); // el.store + el.api (AGENTS.md "State through stores"): no records yet = loading bindComponent(grid, dataGridApi, { name: grid._source ? 'default' : 'loading', config }); dataGridApi.setState(grid, grid._source && !(grid._load && !grid._source.rows.length) ? 'default' : 'loading', config); if (grid._load && !grid._source.rows.length) onNearEnd(grid, true); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub