Add <wpd-table> — data-driven, DX-first table component - #33
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new
<wpd-table>web component to the wpd-ui kit. Assigncolumnsanddata, 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:What you get
Feature list
column.filter,wpd-table-filter-change,clearFilters()column.sortable,column.sortValue,wpd-table-sort-change,clearSort()sticky-columns="N",column.stickysticky-header+ scrolling containersubTable( row, i ),expand(),collapse(),expandAll(),collapseAll(),wpd-table-expand-changecolumn.render( v, row, i )returnsstring | Node | html\``loading,loading-rows="N", respectsprefers-reduced-motion<slot name="empty">(text fallback viaemptyattr)data-noclickopt-out)wpd-table-row-clickscrollToRow( i )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.editorAPI.Sticky-offset measurement (belt + braces)
Variable-width sticky columns can't be expressed in pure CSS — we measure
offsetWidthafter layout and write cumulativeinset-inline-startper cell. Three independent passes, all idempotent:_paint(fixes the common case where layout has already settled).requestAnimationFramerescheduled passes (catch mid-transition mounts, font swaps, async style applies).ResizeObserveron the inner.scrollelement 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: 0pxwhile the host is visible, the component logs a one-timeconsole.warnwithths[0].offsetWidthand points torecomputeLayout()— 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 overwritingbackground-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:
10body sticky cells (above non-sticky body)20sticky-header non-sticky thead cells (above body sticky)30sticky-column thead cells (above sticky-header non-sticky)40the corner cell (sticky-column AND sticky-header)Selection survives data refreshes
The default
getRowIdis the array index — fine for ephemeral lists. Whenever rows have a natural identifier (row.id,row.email), passgetRowId = ( row ) => row.idand selections survivedatareassignment, sort changes, and filter changes. Index-based ids are documented as the explicit fallback, not the recommended default.Architectural fix in the base
ComponentAttribute changes on a wpd-ui component used to bypass
requestUpdate()—attributeChangedCallbackcalled_scheduleRender()directly. That broke any subclass with extra work hooked intorequestUpdate(notablyWpdTable's imperative paint pipeline): toggling an attribute re-rendered the templated skeleton but never re-painted the body. Result: settingloading="true"left the data rows on screen with no skeleton.Fix: route
connectedCallback,attributeChangedCallback, and the prop setter all throughthis.requestUpdate(). Transparent for every existing component (the defaultrequestUpdateis_scheduleRender); fixes the attribute path for any subclass that overridesrequestUpdate. A regression test togglesloadingviasetAttribute(no JS-prop reassignment) and asserts skeleton rows appear, locking in the fix.A diagnostic warning was added too: if
loadingis set and_paintfinishes with no.skeletonrows in the body, the component logs a one-timeconsole.warnnaming the likely cause (stale registered class) plus the workaround (data = datato force a paint). Same tripwire pattern as the sticky-columns 0px warning.Events
event.detailwpd-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 }(skipsdata-noclick)wpd-table-expand-change{ row, index, expanded }Files changed
Tests
ResizeObserverlifecycle /recomputeLayout()/ live attribute toggling viasetAttribute.tsc --noEmitclean.Pixel layout (sticky
leftoffsets, sticky-header pinning) isn't asserted in jsdom — it doesn't lay out CSS, sooffsetWidthis always 0. Class application + lifecycle wiring is what's covered; visual correctness is verified manually in the showcase.Documentation
docs/examples/data-table.mdcovers:WpdTable<T>data-noclickCLAUDE.mddoc-tree was updated so future agents know to read / updatedata-table.mdwhenever the component contract changes.Test plan
<wpd-table>with a sample datasetselectable="multi": header select-all + per-row checkboxes; selection persists acrossdatareassignment whengetRowIdis setsticky-columns="2"with 6+ columns: leftmost two stay pinned on horizontal scroll, opaque, with the soft drop-shadow on the band edgesticky-header+--wpd-table-max-height: 400px: header pins on vertical scrollloadingattribute live (viasetAttribute, no JS-prop reassignment): skeleton rows appear; toggle off → data rows returnsubTable: expander reveals nested table;expandAll()/collapseAll()work<slot name="empty">renders a CTA when data is empty (or filtered to nothing)console.warn)