Data grid
A client-side grid over a realistic synthetic book — sort by clicking a header, filter with the search box, and page through the results. Everything below is deterministic generated data; nothing here is real or client data. The grid is keyboard-accessible and announces its current sort to screen readers via aria-sort.
Total balance$52,605,62560 accounts
Accounts60synthetic
Active accounts5388% of book
Average balance$876,760per account
Accounts
Sortable on every column except the formatted balance. Balance is carried twice — a numeric column for accurate sorting and a currency-formatted display column — so the figures read as money without breaking numeric order.
| Balance | |||||
|---|---|---|---|---|---|
| Atlas Holdings | Treasury | EMEA | 2012 | active | $1,293,956 |
| Meridian Trust | Retirement | APAC | 2020 | active | $478,814 |
| Meridian Partners | Savings | North America | 2025 | active | $990,431 |
| Cedar Capital | Checking | LATAM | 2016 | active | $288,205 |
| Atlas Group | Brokerage | APAC | 2025 | active | $1,150,952 |
| Cedar Ventures | Checking | North America | 2013 | active | $325,705 |
| Atlas Ventures | Savings | APAC | 2024 | active | $790,857 |
| Northwind Capital | Savings | EMEA | 2024 | active | $258,917 |
View sourcedata-grid.ts
import {
ChangeDetectionStrategy,
Component,
ElementRef,
computed,
effect,
input,
signal,
viewChild,
} from '@angular/core';
import { Input } from '../forms/input/input';
import { Pagination } from '../pagination/pagination';
export interface DataGridColumn {
/*
* The row property this column reads. It's the join between data and display: the same key
* drives what the cell renders, what the filter searches, and what the sort compares — so a
* column is described once and the three pipelines stay in agreement automatically.
*/
key: string;
header: string;
sortable?: boolean;
align?: 'start' | 'end';
}
export type SortDirection = 'asc' | 'desc' | null;
export interface DataGridSort {
key: string;
direction: SortDirection;
}
/*
* A client-side data grid: filter, sort, paginate. The design idea worth taking away is that
* none of those three are events that mutate a stored "current rows" array. They're a
* pipeline of `computed()` derivations — filtered → sorted → paged — and the rendered table
* is a pure function of `rows` plus three small pieces of view state (`query`, `sort`,
* `page`). Change any input and the whole pipeline recomputes downstream of it; nothing
* upstream is touched. That's why there's no "refresh the grid" method anywhere: there's
* nothing to refresh, only inputs to set. It's the same source-vs-derived discipline that
* keeps a spreadsheet honest — you edit cells, the totals recompute themselves.
*
* The order of the pipeline is a decision, not an accident: filter first so sort and
* pagination only ever work over the rows that survived the search, and paginate last so the
* page count reflects what the user can actually see. And it renders a real `<table>` with
* `<th scope="col">` headers over `foo-pagination`, not a stack of styled divs — so screen
* readers announce row/column relationships and keyboard users get a genuine table to drive.
*
* <foo-data-grid [columns]="columns" [rows]="rows" [pageSize]="20" />
*/
@Component({
selector: 'foo-data-grid',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Input, Pagination],
template: `
<div class="foo-data-grid">
@if (filterable()) {
<div class="foo-data-grid__toolbar">
<label class="foo-data-grid__search">
<span class="foo-data-grid__search-label">Filter</span>
<input
foo-input
fieldSize="sm"
type="search"
placeholder="Search…"
[value]="query()"
(input)="onQuery($event)"
/>
</label>
<span class="foo-data-grid__count" role="status" aria-live="polite">
{{ filtered().length }}
{{ filtered().length === 1 ? 'result' : 'results' }}
</span>
</div>
}
<div
class="foo-data-grid__scroll-frame"
[class.foo-data-grid__scroll-frame--fade-start]="canScrollStart()"
[class.foo-data-grid__scroll-frame--fade-end]="canScrollEnd()"
>
<div #scroll class="foo-data-grid__scroll" (scroll)="onScroll()">
<table class="foo-data-grid__table">
<thead>
<tr>
@for (col of columns(); track col.key) {
<th
scope="col"
class="foo-data-grid__th"
[class.foo-data-grid__th--end]="col.align === 'end'"
[class.foo-data-grid__th--sortable]="col.sortable"
[attr.aria-sort]="ariaSortFor(col)"
>
@if (col.sortable) {
<button
class="foo-data-grid__sort"
type="button"
(click)="toggleSort(col.key)"
>
<span>{{ col.header }}</span>
<span class="foo-data-grid__sort-indicator" aria-hidden="true">
{{ sortIndicator(col.key) }}
</span>
</button>
} @else {
{{ col.header }}
}
</th>
}
</tr>
</thead>
<tbody>
@for (row of paged(); track $index) {
<tr class="foo-data-grid__row">
@for (col of columns(); track col.key) {
<td
class="foo-data-grid__td"
[class.foo-data-grid__td--end]="col.align === 'end'"
>
{{ display(row[col.key]) }}
</td>
}
</tr>
} @empty {
<tr class="foo-data-grid__empty">
<td class="foo-data-grid__td" [attr.colspan]="columns().length">
No matching rows.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@if (showSwipeHint()) {
<p class="foo-data-grid__swipe-hint" aria-hidden="true">
<span class="foo-data-grid__swipe-arrow">→</span> Swipe to see more
</p>
}
@if (pageCount() > 1) {
<div class="foo-data-grid__footer">
<foo-pagination
[totalItems]="filtered().length"
[pageSize]="pageSize()"
[page]="page()"
[label]="paginationLabel()"
(pageChange)="page.set($event)"
/>
</div>
}
</div>
`,
styleUrl: './data-grid.scss',
})
export class DataGrid<T extends Record<string, unknown> = Record<string, unknown>> {
readonly columns = input<DataGridColumn[]>([]);
readonly rows = input<T[]>([]);
readonly pageSize = input(10);
readonly filterable = input(true);
/*
* Names this grid for assistive tech. Two grids on one page would otherwise give their
* paginators the same "Pagination" landmark name (best-practice: landmarks must be unique),
* so the label flows through to a distinct "<label> pagination" per grid.
*/
readonly label = input('');
protected readonly paginationLabel = computed(() => {
const name = this.label().trim();
return name ? `${name} pagination` : 'Pagination';
});
protected readonly query = signal('');
protected readonly sort = signal<DataGridSort | null>(null);
protected readonly page = signal(1);
private readonly scrollBox = viewChild<ElementRef<HTMLElement>>('scroll');
/*
* The scroll affordance is driven by three measured numbers — how far we've scrolled, the
* full scrollable width, and the visible width — sampled into signals on every scroll event
* (and once after each render, since paging/filtering can change the content width). Keeping
* them as plain numbers rather than pre-computed booleans means the "can scroll left/right"
* and "show the hint" derivations stay readable computeds, and a 1px tolerance absorbs the
* sub-pixel rounding that would otherwise leave a fade stuck on at the very end of a scroll.
*/
private readonly scrollLeft = signal(0);
private readonly scrollWidth = signal(0);
private readonly clientWidth = signal(0);
// More content sits to the left once we've scrolled off the start — fade that edge.
protected readonly canScrollStart = computed(() => this.scrollLeft() > 1);
// More content sits to the right while the remaining scroll distance exceeds a pixel.
protected readonly canScrollEnd = computed(
() => this.scrollWidth() - this.clientWidth() - this.scrollLeft() > 1,
);
/*
* The hint earns its place only at the moment it helps: the grid overflows and the user
* hasn't moved it yet. Once they scroll a pixel, canScrollStart flips true and the hint
* retires — it has done its one job. It's aria-hidden in the template, so this is purely a
* sighted-user nudge and never reaches the a11y tree.
*/
protected readonly showSwipeHint = computed(
() => this.canScrollEnd() && !this.canScrollStart(),
);
constructor() {
/*
* Re-measure after every render pass. Filtering, sorting, and paging all swap the rendered
* rows, which can change the table's intrinsic width — so the affordance can't be measured
* once on init. Reading paged() here ties this effect to the same derivation the table
* renders from, so it re-runs exactly when the content the user sees changes.
*/
effect(() => {
this.paged();
this.measure();
});
/*
* Filtering and pagination interact in a way that bites users if you ignore it. Say
* they're on page 5 of unfiltered results, then type a query that matches only eight
* rows — page 5 no longer exists, and the grid renders empty as if nothing matched. So
* whenever the query changes, snap back to page 1. I track the previous query by hand
* rather than reacting to `page` itself, because the effect only cares about the *query*
* edge; reading page here would re-run it on every page click and fight the user's paging.
*/
let lastQuery = this.query();
effect(() => {
const q = this.query();
if (q !== lastQuery) {
lastQuery = q;
this.page.set(1);
}
});
}
/*
* Stage one of the pipeline. The filter is a deliberately dumb "does any visible column
* contain this text" match — lowercased on both sides so it's case-insensitive, and run
* through the same `stringify` the cells use so what you search is exactly what you see. The
* empty-query fast path returns the original array by reference (no copy), which lets the
* downstream computeds skip work when nobody's filtering.
*/
protected readonly filtered = computed<T[]>(() => {
const q = this.query().trim().toLowerCase();
const rows = this.rows();
if (!q) {
return rows;
}
const cols = this.columns();
return rows.filter((row) =>
cols.some((col) => this.stringify(row[col.key]).toLowerCase().includes(q)),
);
});
/*
* Stage two: sort the *filtered* set, not the raw rows — which is why this reads
* `this.filtered()` and not `this.rows()`. With no active sort it passes the filtered array
* straight through untouched, so the unsorted, unfiltered common case stays zero-copy all
* the way down.
*/
protected readonly sorted = computed<T[]>(() => {
const sort = this.sort();
const rows = this.filtered();
if (!sort || !sort.direction) {
return rows;
}
const { key, direction } = sort;
/*
* Sort ascending always, then flip with a sign factor for descending — one comparator,
* two directions, instead of branching the comparison logic.
*/
const factor = direction === 'asc' ? 1 : -1;
/*
* `Array.prototype.sort` mutates in place, and `rows` here is the array a *parent* passed
* into the `rows` input. Sorting it directly would reorder the caller's data as a side
* effect of rendering — the kind of action-at-a-distance bug that takes an afternoon to
* trace. Spread into a fresh array first; a derivation must never reach back and mutate
* its own source.
*/
return [...rows].sort((a, b) => this.compare(a[key], b[key]) * factor);
});
/*
* Page count comes off the *filtered* length, not the sorted one — sorting reorders rows
* but never changes how many there are, so reading `filtered()` here is the cheaper
* dependency and the right one. The `Math.max(1, …)` floor matters: an empty result still
* has one page, so the pager and the "no rows" message agree instead of fighting over zero.
*/
protected readonly pageCount = computed(() => {
const size = this.pageSize();
if (size <= 0) {
return 1;
}
return Math.max(1, Math.ceil(this.filtered().length / size));
});
/*
* Stage three, the last slice: take the current page's window out of the sorted set. This
* is the array the template actually renders, and it's deliberately the smallest one —
* filtering and sorting did the heavy lifting upstream so this stage only ever cuts a slice.
*/
protected readonly paged = computed<T[]>(() => {
const size = this.pageSize();
if (size <= 0) {
return this.sorted();
}
const start = (this.page() - 1) * size;
return this.sorted().slice(start, start + size);
});
protected onQuery(event: Event): void {
this.query.set((event.target as HTMLInputElement).value);
}
protected onScroll(): void {
this.measure();
}
// Sample the scroll box's geometry into signals. Guarded for the view-child not yet resolved
// (first effect run before the template paints) so it's safe to call eagerly.
private measure(): void {
const el = this.scrollBox()?.nativeElement;
if (!el) {
return;
}
this.scrollLeft.set(el.scrollLeft);
this.scrollWidth.set(el.scrollWidth);
this.clientWidth.set(el.clientWidth);
}
protected toggleSort(key: string): void {
const current = this.sort();
/*
* Three-state cycle on repeat clicks: asc → desc → none → asc. The "none" state lets a
* user undo a sort and get their original row order back, which a two-state toggle can't
* do. The representation choice keeps this simple: "none" is stored as a null sort, never
* as a sort object carrying a null direction. Because there's only ever one way to mean
* "unsorted", a re-click on the same column always finds either asc or desc here — there's
* no awkward third stored shape to disambiguate. Pick the encoding that erases edge cases.
*/
let direction: SortDirection = 'asc';
if (current && current.key === key) {
direction = current.direction === 'asc' ? 'desc' : null;
}
this.sort.set(direction ? { key, direction } : null);
}
protected ariaSortFor(col: DataGridColumn): 'ascending' | 'descending' | 'none' | null {
if (!col.sortable) {
return null;
}
const sort = this.sort();
if (!sort || sort.key !== col.key || !sort.direction) {
return 'none';
}
return sort.direction === 'asc' ? 'ascending' : 'descending';
}
protected sortIndicator(key: string): string {
const sort = this.sort();
if (!sort || sort.key !== key || !sort.direction) {
return '↕';
}
return sort.direction === 'asc' ? '↑' : '↓';
}
/*
* Here's the "carry the balance twice" idea, and it's the subtle part of any grid. A value
* has two jobs that pull in different directions: how it *looks* and how it *orders*. Sort a
* column of numbers as the strings they render to and you get "1, 10, 2, 20, 3" — text order,
* not magnitude. So the grid never sorts the displayed string. `display`/`stringify` own the
* visible text; `compare` (below) reaches back to the raw typed value and orders by what it
* actually is. Same data, two representations, deliberately kept separate.
*/
protected display(value: unknown): string {
return this.stringify(value);
}
/*
* The display half: coerce anything to a string for the cell, and — importantly — turn
* null/undefined into "" rather than the literal text "null". An empty cell reads as empty;
* the word "null" leaking into a table is the kind of polish gap users notice immediately.
*/
private stringify(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
return String(value);
}
/*
* The ordering half, working on raw values, not display strings. The ladder is ordered on
* purpose: equality first (cheap exit), then null/undefined sink to the bottom so blanks
* don't scatter through the data, then a true numeric subtraction when both sides are
* numbers so 2 sorts before 10. Only when types are mixed or non-numeric does it fall back
* to `localeCompare` with `numeric: true`, which still sorts "item 2" before "item 10".
*/
private compare(a: unknown, b: unknown): number {
if (a === b) {
return 0;
}
if (a === null || a === undefined) {
return -1;
}
if (b === null || b === undefined) {
return 1;
}
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
return this.stringify(a).localeCompare(this.stringify(b), undefined, { numeric: true });
}
}
Recent transactions
Most-recent-first, debits shown negative. Amount is formatted for display; sort the grid by date, description, category, or status.
| Amount | ||||
|---|---|---|---|---|
| 2026-05-31 | Payroll — Northwind Logistics | Payroll | cleared | ($234) |
| 2026-05-30 | Refund — IRS | Tax | cleared | ($8,187) |
| 2026-05-29 | Dividend — IRS | Payroll | cleared | ($18,117) |
| 2026-05-28 | Payroll — IRS | Transfer | cleared | $2,548 |
| 2026-05-28 | Transfer — Atlas Lease | Transfer | cleared | ($20,309) |
| 2026-05-26 | Dividend — Northwind Logistics | Fees | cleared | ($2,353) |
| 2026-05-25 | Vendor — Cedar Payroll | Refund | cleared | ($3,295) |
| 2026-05-24 | Payroll — Cedar Payroll | Refund | cleared | $27,880 |