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.

60 results
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
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.

45 results
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