Skip to content

Add <wpd-table> — data-driven, DX-first table component - #33

Merged
AllTerrainDeveloper merged 2 commits into
trunkfrom
feat/new-table-component
Apr 28, 2026
Merged

Add <wpd-table> — data-driven, DX-first table component#33
AllTerrainDeveloper merged 2 commits into
trunkfrom
feat/new-table-component

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a new <wpd-table> web component to the wpd-ui kit. Assign columns and data, get a styled, accessible, sticky-aware data table. Per-column filters, click-to-sort, multi-row selection, sticky columns/header, sub-tables, custom cell renderers, loading skeleton, and a slottable empty state are all opt-in.

Table.demo.mov

Plugin Showcase Demo:
wpd-table-showcase.zip

Why

Plugin authors rendering admin data inside native windows kept reaching for hand-rolled <table> markup, copy-pasting sticky-column CSS, and reinventing filter/sort UI per scene. The pattern was repetitive enough to warrant a primitive — but only if the primitive didn't lock anyone in. Two non-negotiables:

  1. Easy to start, possible to extend. A 10-line table works; a 200-line table works; the path between is visible in the docs.
  2. Pure additive. No assumptions about the host's data layer, framework, or styling language. Pass a typed array, get rows back.

What you get

import type { WpdTable, WpdTableColumn } from 'wp-desktop/ui';

interface User extends Record< string, unknown > {
    name: string;
    email: string;
    role: 'admin' | 'editor';
    logins: number;
}

const table = document.querySelector< WpdTable< User > >( '#users' )!;
table.columns = [
    { key: 'name',   label: 'Name',   filter: 'text', sortable: true, sticky: true },
    { key: 'email',  label: 'Email',  filter: 'text', sortable: true },
    { key: 'role',   label: 'Role',   filter: 'select' },
    { key: 'logins', label: 'Logins', align: 'end',   sortable: true },
];
table.data = users;
table.getRowId = ( row ) => row.email;       // stable selection across refreshes
table.subTable = ( row ) => row.history?.length
    ? { columns: historyCols, data: row.history }
    : null;
<wpd-table sticky-columns="2" sticky-header striped hover selectable="multi">
    <div slot="empty">
        <p>No users yet.</p>
        <wpd-button>Invite someone</wpd-button>
    </div>
</wpd-table>

Feature list

Feature Surface
Per-column text / select filters column.filter, wpd-table-filter-change, clearFilters()
Click-to-sort (asc → desc → unsorted) column.sortable, column.sortValue, wpd-table-sort-change, clearSort()
Multi-row + single-row selection `selectable="single
Sticky columns (variable width, RTL-aware) sticky-columns="N", column.sticky
Sticky header (with filter row) sticky-header + scrolling container
Sub-tables (infinitely nestable) subTable( row, i ), expand(), collapse(), expandAll(), collapseAll(), wpd-table-expand-change
Custom cell renderers column.render( v, row, i ) returns string | Node | html\``
Loading skeleton loading, loading-rows="N", respects prefers-reduced-motion
Slottable empty state <slot name="empty"> (text fallback via empty attr)
Row clicks (with data-noclick opt-out) wpd-table-row-click
Programmatic scroll scrollToRow( i )
Public layout escape hatch recomputeLayout()

Generic over the row type — WpdTable<User> gives strong types for every callback.

Notable design decisions

Imperative paint inside a templated skeleton

The wpd-ui html\`renderer parses every nested template viatemplate.innerHTML, which applies HTML's content-model rules — so a sub-template containing , , or ` gets hoisted out of its expected parent and the table breaks. The component renders a static skeleton via the template tag, then paints headers / rows / cells imperatively.

Filter inputs are kept across paints (not rebuilt) so typing into a filter never loses focus or caret position — this is the same pattern that would generalize to a future column.editor API.

Sticky-offset measurement (belt + braces)

Variable-width sticky columns can't be expressed in pure CSS — we measure offsetWidth after layout and write cumulative inset-inline-start per cell. Three independent passes, all idempotent:

  1. Synchronous read at the end of _paint (fixes the common case where layout has already settled).
  2. Microtask + requestAnimationFrame rescheduled passes (catch mid-transition mounts, font swaps, async style applies).
  3. ResizeObserver on the inner .scroll element AND the host (catches scrollbar appearance, panel-driven reflow, hidden→visible transitions, container query crossings).

If a column-1+ sticky cell ever ends up at inset-inline-start: 0px while the host is visible, the component logs a one-time console.warn with ths[0].offsetWidth and points to recomputeLayout() — a tripwire that should never fire, but if it does, names the bug instead of leaving the dev in DevTools.

Sticky-cell opacity invariant

Sticky cells need an opaque background (non-sticky siblings slide under them on scroll). Stripe / hover / sub-table state overlays therefore layer via background-image: linear-gradient(rgba, rgba) rather than overwriting background-color. The opaque base is always preserved; combinations like striped+hover stack the overlays without ever exposing transparency.

Z-index ladder is widely spaced (10 / 20 / 30 / 40) so there's no ambiguity across browser table-cell stacking quirks:

  • 10 body sticky cells (above non-sticky body)
  • 20 sticky-header non-sticky thead cells (above body sticky)
  • 30 sticky-column thead cells (above sticky-header non-sticky)
  • 40 the corner cell (sticky-column AND sticky-header)

Selection survives data refreshes

The default getRowId is the array index — fine for ephemeral lists. Whenever rows have a natural identifier (row.id, row.email), pass getRowId = ( row ) => row.id and selections survive data reassignment, sort changes, and filter changes. Index-based ids are documented as the explicit fallback, not the recommended default.

Architectural fix in the base Component

Attribute changes on a wpd-ui component used to bypass requestUpdate()attributeChangedCallback called _scheduleRender() directly. That broke any subclass with extra work hooked into requestUpdate (notably WpdTable's imperative paint pipeline): toggling an attribute re-rendered the templated skeleton but never re-painted the body. Result: setting loading="true" left the data rows on screen with no skeleton.

Fix: route connectedCallback, attributeChangedCallback, and the prop setter all through this.requestUpdate(). Transparent for every existing component (the default requestUpdate is _scheduleRender); fixes the attribute path for any subclass that overrides requestUpdate. A regression test toggles loading via setAttribute (no JS-prop reassignment) and asserts skeleton rows appear, locking in the fix.

A diagnostic warning was added too: if loading is set and _paint finishes with no .skeleton rows in the body, the component logs a one-time console.warn naming the likely cause (stale registered class) plus the workaround (data = data to force a paint). Same tripwire pattern as the sticky-columns 0px warning.

Events

Name event.detail
wpd-table-filter-change { filters }
wpd-table-sort-change { sort } (or { sort: null })
wpd-table-selection-change { selection: id[], rows: T[] }
wpd-table-row-click { row, index, originalEvent } (skips data-noclick)
wpd-table-expand-change { row, index, expanded }

Files changed

src/ui/components/wpd-table/
├── wpd-table.ts             ~1100 lines — component
├── wpd-table.styles.ts      Cell-opacity invariant + z-index ladder
└── wpd-table.test.ts        23 vitest tests

src/ui/core/component.ts     attributeChangedCallback / connectedCallback /
                             prop setter routed through requestUpdate()

src/ui/components/index.ts   Barrel export + WPD_COMPONENT_TAGS

docs/examples/data-table.md  Full DX-oriented example with TypeScript,
                             sort, selection, sticky-columns counting,
                             editable cells, common pitfalls, programmatic
                             API reference

docs/examples/README.md      Example index entry
CLAUDE.md                    docs/examples doc-tree map entry

Tests

  • 23 component tests covering empty / data-rendering / filter / sort / selection / sub-table / expand-API / loading / slot / sticky classes / ResizeObserver lifecycle / recomputeLayout() / live attribute toggling via setAttribute.
  • Full project test suite: 513/513 green.
  • Lint + tsc --noEmit clean.
  • Both Vite builds (dev + prod) clean.

Pixel layout (sticky left offsets, sticky-header pinning) isn't asserted in jsdom — it doesn't lay out CSS, so offsetWidth is always 0. Class application + lifecycle wiring is what's covered; visual correctness is verified manually in the showcase.

Documentation

docs/examples/data-table.md covers:

  • Minimum viable table (10-line happy path)
  • TypeScript usage with the generic WpdTable<T>
  • Per-column filters + persistence
  • Sort (built-in and "react to header clicks for server-side sort")
  • Selection with stable ids
  • Sticky columns/header + sticky-columns counting worked example (auto-prepended expander/select columns count toward N)
  • Sub-tables (infinite nesting, custom expanded content)
  • Loading state
  • Empty slot with CTA
  • Custom cell renderers + best-practice (one return shape per column)
  • Editable cells (with the focus-preservation footgun called out)
  • Row clicks + data-noclick
  • Full programmatic API reference
  • Attributes / CSS custom properties / events / slots
  • Common pitfalls (sticky-header without scroll container, sticky-columns counting, editable cells losing focus, selection going stale)

CLAUDE.md doc-tree was updated so future agents know to read / update data-table.md whenever the component contract changes.

Test plan

  • Open a native window, render <wpd-table> with a sample dataset
  • Verify per-column filtering narrows rows in real time without losing input focus
  • Click sortable headers → asc → desc → unsorted cycle works
  • selectable="multi": header select-all + per-row checkboxes; selection persists across data reassignment when getRowId is set
  • sticky-columns="2" with 6+ columns: leftmost two stay pinned on horizontal scroll, opaque, with the soft drop-shadow on the band edge
  • sticky-header + --wpd-table-max-height: 400px: header pins on vertical scroll
  • Toggle loading attribute live (via setAttribute, no JS-prop reassignment): skeleton rows appear; toggle off → data rows return
  • subTable: expander reveals nested table; expandAll() / collapseAll() work
  • <slot name="empty"> renders a CTA when data is empty (or filtered to nothing)
  • RTL: sticky columns pin to the inline-start (right edge in RTL); shadow flips
  • Resize the window, hide/show the panel: sticky offsets stay correct (no console.warn)
Open WordPress Playground Preview

@AllTerrainDeveloper
AllTerrainDeveloper enabled auto-merge (squash) April 28, 2026 08:50
@AllTerrainDeveloper
AllTerrainDeveloper merged commit b85e5b1 into trunk Apr 28, 2026
7 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the feat/new-table-component branch April 28, 2026 08:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant