From bbd00043bbf4e054cb8599529603461fdc62a7ea Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Fri, 10 Jul 2026 11:00:52 -0500 Subject: [PATCH] feat(components): add DataTable (RAC + TanStack v8) [spike] Higher-level data table composed on the LaunchPad RAC Table + TanStack Table v8: declarative columns + data with freeform search, sorting, row selection, column visibility, resize, density, click-row-to-open, and column drag-reorder. Adds cell content helpers (two-line, numeric, delta, status, tag, mono) matching the Figma "Table BETA" system, plus header/border styling from the o11y traces design. Spike (spike/launchpad-datatable) for design review via Chromatic; see spikes/launchpad-datatable/SPEC.md. Keyboard column-reorder is a documented follow-up. --- packages/components/package.json | 1 + packages/components/src/DataTable.tsx | 358 ++++++++++++ packages/components/src/DataTableCells.tsx | 106 ++++ packages/components/src/index.ts | 11 + .../src/styles/DataTable.module.css | 104 ++++ .../src/styles/DataTableCells.module.css | 85 +++ .../components/stories/DataTable.stories.tsx | 550 ++++++++++++++++++ pnpm-lock.yaml | 25 +- spikes/launchpad-datatable/SPEC.md | 356 ++++++++++++ 9 files changed, 1595 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/DataTable.tsx create mode 100644 packages/components/src/DataTableCells.tsx create mode 100644 packages/components/src/styles/DataTable.module.css create mode 100644 packages/components/src/styles/DataTableCells.module.css create mode 100644 packages/components/stories/DataTable.stories.tsx create mode 100644 spikes/launchpad-datatable/SPEC.md diff --git a/packages/components/package.json b/packages/components/package.json index faa3f6b4b..747582f12 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -34,6 +34,7 @@ "@launchpad-ui/icons": "workspace:~", "@launchpad-ui/tokens": "workspace:~", "@react-aria/live-announcer": "3.4.4", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "0.7.0" }, "devDependencies": { diff --git a/packages/components/src/DataTable.tsx b/packages/components/src/DataTable.tsx new file mode 100644 index 000000000..d14546eec --- /dev/null +++ b/packages/components/src/DataTable.tsx @@ -0,0 +1,358 @@ +import type { ColumnDef, RowData, SortingState, VisibilityState } from '@tanstack/react-table'; +import type { Ref } from 'react'; +import type { Selection as AriaSelection, Key, SortDescriptor } from 'react-aria-components'; + +import { Icon } from '@launchpad-ui/icons'; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { useState } from 'react'; + +import { Group } from './Group'; +import { IconButton } from './IconButton'; +import { Input } from './Input'; +import { Menu, MenuItem, MenuTrigger } from './Menu'; +import { Popover } from './Popover'; +import { SearchField } from './SearchField'; +import styles from './styles/DataTable.module.css'; +import { Cell, Column, ResizableTableContainer, Row, Table, TableBody, TableHeader } from './Table'; + +// Augment TanStack column meta with the extra hints DataTable needs. `label` gives +// the column-visibility menu a human string when `header` is a render function; +// `isRowHeader` marks which column RAC should treat as the row's header cell. +declare module '@tanstack/react-table' { + // biome-ignore lint/correctness/noUnusedVariables: augmentation must match TanStack's generic signature + interface ColumnMeta { + label?: string; + isRowHeader?: boolean; + /** Horizontal alignment; `end` right-aligns header + cells (numeric/currency/delta). */ + align?: 'start' | 'end'; + /** Set false to pin a column (excluded from drag-reorder, e.g. a row-actions column). */ + enableReorder?: boolean; + } +} + +interface DataTableProps { + /** Accessible name for the table. */ + 'aria-label': string; + /** Row data. */ + data: TData[]; + /** TanStack column definitions (accessorKey/accessorFn, header, cell, enableSorting, enableHiding, meta). */ + columns: ColumnDef[]; + /** Stable row id — used as the RAC row key and for selection. Defaults to the row index. */ + getRowId?: (row: TData, index: number) => string; + /** Initial sort (uncontrolled), e.g. `[{ id: 'status', desc: true }]`. */ + defaultSorting?: SortingState; + /** Row density, matching the Figma table system. Default `default`. */ + density?: 'default' | 'compact' | 'tight'; + /** RAC selection mode. Selection is delegated to RAC (checkbox column injected automatically). */ + selectionMode?: 'none' | 'single' | 'multiple'; + /** + * `toggle` (default) shows a checkbox column; `replace` selects the whole row on click with + * a highlight and no checkbox — the list/detail pattern (click a trace row to open it). + */ + selectionBehavior?: 'toggle' | 'replace'; + selectedKeys?: AriaSelection; + onSelectionChange?: (keys: AriaSelection) => void; + /** + * Called when a row is activated (click or Enter) — the click-row-to-open-detail pattern + * (e.g. a traces list opening a trace). Receives the row id. RAC suppresses this when the + * press lands on an interactive cell (checkbox, actions menu). + */ + onRowAction?: (id: string) => void; + /** Show the freeform search field in the toolbar (TanStack global filter). Default true. */ + enableGlobalFilter?: boolean; + /** Show the column-visibility menu in the toolbar (TanStack column visibility). Default true. */ + enableColumnVisibility?: boolean; + /** Wrap in a RAC ResizableTableContainer so columns can be resized. Default false. */ + enableColumnResize?: boolean; + /** + * Allow reordering columns by dragging a grip handle in the header (TanStack `columnOrder`). + * RAC has no native column DnD, so this uses lightweight HTML5 drag — mouse-based; keyboard + * reorder is a follow-up. Pin a column with `meta.enableReorder: false`. + */ + enableColumnReorder?: boolean; + ref?: Ref; +} + +function columnLabel(columnDef: ColumnDef, id: string): string { + if (columnDef.meta?.label) { + return columnDef.meta.label; + } + if (typeof columnDef.header === 'string') { + return columnDef.header; + } + return id; +} + +/** + * A data table takes declarative `columns` + `data` and layers the data-heavy interactions + * product surfaces expect — freeform search, sorting, row selection, column visibility, and + * resize — on top of the LaunchPad RAC `Table`. + * + * Division of labor: RAC owns markup, keyboard nav, a11y, selection, sort UI, DnD, and resize; + * TanStack Table v8 is the headless model for filtering, sorting, and column visibility. + */ +const DataTable = ({ + data, + columns, + getRowId, + defaultSorting, + density = 'default', + selectionMode = 'none', + selectionBehavior, + selectedKeys, + onSelectionChange, + onRowAction, + enableGlobalFilter = true, + enableColumnVisibility = true, + enableColumnResize = false, + enableColumnReorder = false, + ref, + ...props +}: DataTableProps) => { + const [sorting, setSorting] = useState(defaultSorting ?? []); + const [globalFilter, setGlobalFilter] = useState(''); + const [columnVisibility, setColumnVisibility] = useState({}); + const [columnOrder, setColumnOrder] = useState([]); + // Column drag-reorder (HTML5 DnD): the column being dragged and the current drop target. + const [dragColumnId, setDragColumnId] = useState(null); + const [dropColumnId, setDropColumnId] = useState(null); + + const table = useReactTable({ + data, + columns, + state: { sorting, globalFilter, columnVisibility, columnOrder }, + getRowId, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + onColumnVisibilityChange: setColumnVisibility, + onColumnOrderChange: setColumnOrder, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + enableSortingRemoval: false, + }); + + // Move `fromId` to `toId`'s slot in the full leaf-column order and commit it to TanStack, + // which re-derives both header and cell order (RAC then re-renders in lockstep). + const reorderColumn = (fromId: string, toId: string) => { + const order = table.getAllLeafColumns().map((column) => column.id); + const from = order.indexOf(fromId); + const to = order.indexOf(toId); + if (from === -1 || to === -1 || from === to) { + return; + } + order.splice(from, 1); + order.splice(to, 0, fromId); + setColumnOrder(order); + }; + + // Bridge: derive RAC's single-column SortDescriptor from TanStack sorting state, and + // translate RAC sort changes back into TanStack state so the sort UI is RAC's but the + // actual ordering is computed by TanStack's row model. + const sort = sorting[0]; + const sortDescriptor: SortDescriptor | undefined = sort + ? { column: sort.id, direction: sort.desc ? 'descending' : 'ascending' } + : undefined; + const onSortChange = (descriptor: SortDescriptor) => { + setSorting([{ id: String(descriptor.column), desc: descriptor.direction === 'descending' }]); + }; + + const headers = table.getHeaderGroups()[0]?.headers ?? []; + const rows = table.getRowModel().rows; + // RAC memoizes dynamic-collection rows by their stable id, so toggling column + // visibility/order updates the (statically-mapped) header but leaves cached cells + // behind — tripping RAC's "cell count must match column count" invariant. Feeding + // a `dependencies` value that changes with the visible columns invalidates that + // cache in lockstep. It must be a single, constant-size entry: RAC spreads + // `dependencies` into a useMemo deps array, and React requires a stable size. + const visibleColumnsKey = table + .getVisibleLeafColumns() + .map((column) => column.id) + .join('|'); + const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide()); + const visibleColumnKeys = new Set( + hideableColumns.filter((column) => column.getIsVisible()).map((column) => column.id), + ); + + const onColumnSelectionChange = (keys: AriaSelection) => { + const next: VisibilityState = {}; + for (const column of hideableColumns) { + next[column.id] = keys === 'all' || keys.has(column.id); + } + setColumnVisibility(next); + }; + + const tableElement = ( + onRowAction(String(key)) : undefined} + sortDescriptor={sortDescriptor} + onSortChange={onSortChange} + > + + {headers.map((header, index) => { + const { columnDef } = header.column; + const columnId = header.column.id; + const isRowHeader = columnDef.meta?.isRowHeader ?? index === 0; + const reorderable = enableColumnReorder && columnDef.meta?.enableReorder !== false; + const content = flexRender(columnDef.header, header.getContext()); + return ( + + {reorderable ? ( + // biome-ignore lint/a11y/noStaticElementInteractions: HTML5 drop target for column reorder; keyboard reorder is a documented follow-up + { + if (dragColumnId && dragColumnId !== columnId) { + e.preventDefault(); + setDropColumnId(columnId); + } + }} + onDragLeave={() => + setDropColumnId((current) => (current === columnId ? null : current)) + } + onDrop={(e) => { + e.preventDefault(); + if (dragColumnId) { + reorderColumn(dragColumnId, columnId); + } + setDragColumnId(null); + setDropColumnId(null); + }} + > + {/* Grip is the drag origin; stop pointer/click from reaching RAC's sort press. */} + e.stopPropagation()} + onDragStart={(e) => { + setDragColumnId(columnId); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', columnId); + }} + onDragEnd={() => { + setDragColumnId(null); + setDropColumnId(null); + }} + > + + + {content} + + ) : ( + content + )} + + ); + })} + + 'No results found'} + > + {(row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )} + +
+ ); + + const showToolbar = enableGlobalFilter || enableColumnVisibility; + + return ( +
+ {showToolbar && ( +
+ {enableGlobalFilter && ( + + + + + + + + )} + {enableColumnVisibility && hideableColumns.length > 0 && ( + + + + + + {hideableColumns.map((column) => ( + + {columnLabel(column.columnDef, column.id)} + + ))} + + + + + )} +
+ )} + {enableColumnResize ? ( + {tableElement} + ) : ( + tableElement + )} +
+ ); +}; + +export { DataTable }; +export type { DataTableProps }; diff --git a/packages/components/src/DataTableCells.tsx b/packages/components/src/DataTableCells.tsx new file mode 100644 index 000000000..bdd2d1f6b --- /dev/null +++ b/packages/components/src/DataTableCells.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from 'react'; + +import styles from './styles/DataTableCells.module.css'; +import { Tag, TagGroup, TagList } from './TagGroup'; + +const cx = (...classes: (string | false | undefined)[]) => classes.filter(Boolean).join(' '); + +/** + * Cell content patterns from the LaunchPad "Table BETA" design system. Consumers pass + * these as a column's `cell` renderer so tables get the design's cell anatomy — two-line + * text, tabular numerics, colored deltas, status dots, tags, monospace — out of the box. + */ + +interface TwoLineCellProps { + /** Primary line — `label/1/medium`, primary text color. */ + primary: ReactNode; + /** Optional secondary line — `small/1/regular`, muted. */ + secondary?: ReactNode; + /** Optional leading media (icon, avatar) rendered to the left of the text. */ + media?: ReactNode; +} + +/** Text / Composition cell: primary line with optional muted secondary line and leading media. */ +const TwoLineCell = ({ primary, secondary, media }: TwoLineCellProps) => { + const text = ( + + {primary} + {secondary != null && {secondary}} + + ); + return media != null ? ( + + {media} + {text} + + ) : ( + text + ); +}; + +/** Numeric / currency value — tabular figures. Pair with a column `meta.align: 'end'`. */ +const NumericValue = ({ children }: { children: ReactNode }) => ( + {children} +); + +interface DeltaValueProps { + /** The signed value, e.g. 1.2 or -0.4. */ + value: number; + /** Suffix appended to the formatted number. Default `%`. */ + suffix?: string; + /** Fraction digits. Default 1. */ + fractionDigits?: number; + /** Invert the good/bad coloring for "lower is better" metrics (error rate, latency). */ + invert?: boolean; +} + +/** Delta / trend value: signed, tabular, and colored success / error / neutral. */ +const DeltaValue = ({ + value, + suffix = '%', + fractionDigits = 1, + invert = false, +}: DeltaValueProps) => { + const good = invert ? value < 0 : value > 0; + const bad = invert ? value > 0 : value < 0; + const tone = good ? styles.up : bad ? styles.down : styles.zero; + const sign = value > 0 ? '+' : ''; + return ( + + {sign} + {value.toFixed(fractionDigits)} + {suffix} + + ); +}; + +type StatusTone = 'success' | 'error' | 'info' | 'neutral'; + +/** Status cell: a colored dot plus a label (e.g. an environment or health indicator). */ +const StatusCell = ({ tone = 'neutral', children }: { tone?: StatusTone; children: ReactNode }) => ( + + + {children} + +); + +/** Tag list cell (References) — composes the LaunchPad `TagGroup`/`Tag` design-system primitive. */ +const TagCell = ({ tags, label = 'Tags' }: { tags: string[]; label?: string }) => ( + + + {tags.map((tag) => ( + + {tag} + + ))} + + +); + +/** Monospace value (ids, hashes, keys) — CommitMono via the LP code text token. */ +const MonoValue = ({ children }: { children: ReactNode }) => ( + {children} +); + +export { TwoLineCell, NumericValue, DeltaValue, StatusCell, TagCell, MonoValue }; +export type { TwoLineCellProps, DeltaValueProps, StatusTone }; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 7fa3a8bcf..b3be81789 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -20,6 +20,8 @@ export type { CheckboxProps } from './Checkbox'; export type { CheckboxGroupProps } from './CheckboxGroup'; export type { CodeProps } from './Code'; export type { ComboBoxProps } from './ComboBox'; +export type { DataTableProps } from './DataTable'; +export type { DeltaValueProps, StatusTone, TwoLineCellProps } from './DataTableCells'; export type { DateFieldProps, DateInputProps, @@ -145,6 +147,15 @@ export { ComboBoxContext, comboBoxStyles, } from './ComboBox'; +export { DataTable } from './DataTable'; +export { + DeltaValue, + MonoValue, + NumericValue, + StatusCell, + TagCell, + TwoLineCell, +} from './DataTableCells'; export { DateField, DateFieldContext, diff --git a/packages/components/src/styles/DataTable.module.css b/packages/components/src/styles/DataTable.module.css new file mode 100644 index 000000000..b5eb2ac76 --- /dev/null +++ b/packages/components/src/styles/DataTable.module.css @@ -0,0 +1,104 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--lp-spacing-400); + width: 100%; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--lp-spacing-400); +} + +.search { + flex: 1 1 auto; + max-width: 320px; +} + +/* Keep the column-settings control pinned right, even when the search field is hidden. */ +.columnMenu { + margin-left: auto; +} + +/* Header treatment per the o11y traces design (node 2829-54659): muted text, medium (not + semibold) weight, a subtle bg-ui-secondary fill, and a border-ui-primary underline. */ +.root :is(th) { + color: var(--lp-color-text-ui-secondary); + font: var(--lp-text-label-1-medium); + background-color: var(--lp-color-bg-ui-secondary); +} + +.root thead { + border-color: var(--lp-color-border-ui-primary); +} + +/* Row dividers use border-ui-primary (#d8dde3), a touch darker than the base table's + border-ui-secondary — matching the design. */ +.root :is(td) { + border-bottom-color: var(--lp-color-border-ui-primary); +} + +/* Column drag-reorder: a grip handle (revealed on header hover) is the drag origin, and the + drop-target column shows a left insertion line. */ +.reorderHeader { + display: flex; + align-items: center; + gap: var(--lp-spacing-200); + width: 100%; +} + +.grip { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + cursor: grab; + color: var(--lp-color-fill-ui-secondary); + opacity: 0; +} + +.grip:active { + cursor: grabbing; +} + +.root th:hover .grip, +.root th:focus-within .grip { + opacity: 1; +} + +.dropTarget { + box-shadow: inset 2px 0 0 0 var(--lp-color-bg-interactive-primary-base); +} + +/* Right-align numeric/currency/delta columns (header label + cells). Scoped under + .root and element-qualified so it beats the base Table's `text-align: left`. */ +.root :is(th, td).alignEnd { + text-align: right; +} + +.root th.alignEnd > div { + justify-content: flex-end; +} + +/* Interactive-row affordances for the list/detail (click-row-to-open) pattern — the base + LP Table styles only cursor + focus ring, so hover/selected backgrounds live here. */ +.root tbody [role='row'][data-hovered] { + background-color: var(--lp-color-bg-ui-secondary); +} + +.root tbody [role='row'][data-selected] { + background-color: var(--lp-color-bg-ui-tertiary); +} + +/* Row density, mapped to LP spacing tokens: default 8 / compact 4 / tight 0. + Overrides the base Table cell's vertical padding without touching horizontal. */ +.root[data-density='compact'] :is(th, td) { + padding-top: var(--lp-spacing-200); + padding-bottom: var(--lp-spacing-200); +} + +.root[data-density='tight'] :is(th, td) { + padding-top: var(--lp-spacing-100); + padding-bottom: var(--lp-spacing-100); +} diff --git a/packages/components/src/styles/DataTableCells.module.css b/packages/components/src/styles/DataTableCells.module.css new file mode 100644 index 000000000..62778044e --- /dev/null +++ b/packages/components/src/styles/DataTableCells.module.css @@ -0,0 +1,85 @@ +.twoLine { + display: flex; + flex-direction: column; + min-width: 0; +} + +.primary { + font: var(--lp-text-label-1-medium); + color: var(--lp-color-text-ui-primary-base); + overflow: hidden; + text-overflow: ellipsis; +} + +.secondary { + font: var(--lp-text-small-1-regular); + color: var(--lp-color-text-ui-secondary); + overflow: hidden; + text-overflow: ellipsis; +} + +.media { + display: flex; + align-items: center; + gap: var(--lp-spacing-300); + min-width: 0; +} + +/* Leading media (avatar/icon) keeps its intrinsic size; the text block takes the rest and + truncates. Without this the media gets squished into an oval when the text is long. */ +.media > :first-child { + flex: 0 0 auto; +} + +.media > .twoLine { + flex: 1 1 auto; +} + +.numeric { + font-variant-numeric: tabular-nums; +} + +.up { + color: var(--lp-color-text-feedback-success); +} + +.down { + color: var(--lp-color-text-feedback-error); +} + +.zero { + color: var(--lp-color-text-ui-secondary); +} + +.status { + display: flex; + align-items: center; + gap: var(--lp-spacing-300); +} + +.dot { + width: var(--lp-size-8, 0.5rem); + height: var(--lp-size-8, 0.5rem); + border-radius: var(--lp-border-radius-large); + flex: 0 0 auto; +} + +.dot-success { + background-color: var(--lp-color-fill-feedback-success); +} + +.dot-error { + background-color: var(--lp-color-fill-feedback-error); +} + +.dot-info { + background-color: var(--lp-color-fill-feedback-info); +} + +.dot-neutral { + background-color: var(--lp-color-fill-ui-secondary); +} + +.mono { + font: var(--lp-text-code-1-regular); +} diff --git a/packages/components/stories/DataTable.stories.tsx b/packages/components/stories/DataTable.stories.tsx new file mode 100644 index 000000000..57550250b --- /dev/null +++ b/packages/components/stories/DataTable.stories.tsx @@ -0,0 +1,550 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ColumnDef } from '@tanstack/react-table'; + +import { Icon } from '@launchpad-ui/icons'; +import { useState } from 'react'; + +import { InitialsAvatar } from '../src/Avatar'; +import { Button } from '../src/Button'; +import { DataTable } from '../src/DataTable'; +import { + DeltaValue, + MonoValue, + NumericValue, + StatusCell, + TagCell, + TwoLineCell, +} from '../src/DataTableCells'; +import { IconButton } from '../src/IconButton'; +import { Menu, MenuItem, MenuTrigger } from '../src/Menu'; +import { Popover } from '../src/Popover'; +import { Tag, TagGroup, TagList } from '../src/TagGroup'; + +// --------------------------------------------------------------------------- +// Services dataset — content-type kitchen sink (Figma "Table BETA" content set) +// --------------------------------------------------------------------------- + +type Service = { + id: string; + name: string; + description: string; + environments: string[]; + health: 'success' | 'error' | 'info' | 'neutral'; + healthLabel: string; + requests: number; + errorRate: number; + p95: number; + lastSeenDate: string; + lastSeenTime: string; +}; + +const data: Service[] = [ + { + id: 'svc_4f2a', + name: 'checkout-api', + description: 'Payments & cart', + environments: ['prod', 'staging'], + health: 'error', + healthLabel: 'Degraded', + requests: 128_400, + errorRate: 4.2, + p95: 812, + lastSeenDate: '2026-07-08', + lastSeenTime: '14:22', + }, + { + id: 'svc_9c1b', + name: 'api-gateway', + description: 'Edge routing', + environments: ['prod'], + health: 'success', + healthLabel: 'Healthy', + requests: 982_100, + errorRate: -0.3, + p95: 143, + lastSeenDate: '2026-07-08', + lastSeenTime: '14:21', + }, + { + id: 'svc_2d77', + name: 'auth-service', + description: 'Login & sessions', + environments: ['prod', 'staging', 'dev'], + health: 'success', + healthLabel: 'Healthy', + requests: 341_050, + errorRate: 0, + p95: 96, + lastSeenDate: '2026-07-08', + lastSeenTime: '14:20', + }, + { + id: 'svc_8e30', + name: 'mobile-bff', + description: 'Mobile backend', + environments: ['prod'], + health: 'info', + healthLabel: 'Deploying', + requests: 54_900, + errorRate: 1.1, + p95: 268, + lastSeenDate: '2026-07-08', + lastSeenTime: '14:19', + }, + { + id: 'svc_1a54', + name: 'signups-worker', + description: 'Async signups', + environments: ['staging'], + health: 'neutral', + healthLabel: 'Idle', + requests: 2_300, + errorRate: -2.4, + p95: 41, + lastSeenDate: '2026-07-07', + lastSeenTime: '23:58', + }, + { + id: 'svc_6b90', + name: 'payments-ledger', + description: 'Double-entry ledger', + environments: ['prod', 'staging'], + health: 'success', + healthLabel: 'Healthy', + requests: 76_820, + errorRate: 0.4, + p95: 187, + lastSeenDate: '2026-07-08', + lastSeenTime: '14:18', + }, + { + id: 'svc_3f11', + name: 'cdn-edge', + description: 'Static assets', + environments: ['prod'], + health: 'error', + healthLabel: 'Errors', + requests: 1_204_000, + errorRate: 6.8, + p95: 59, + lastSeenDate: '2026-07-08', + lastSeenTime: '14:17', + }, + { + id: 'svc_7c22', + name: 'reporting-db', + description: 'Analytics queries', + environments: ['dev'], + health: 'neutral', + healthLabel: 'Idle', + requests: 410, + errorRate: 0, + p95: 1_240, + lastSeenDate: '2026-07-05', + lastSeenTime: '09:03', + }, +]; + +const ActionsCell = () => ( + + + + + View details + Edit + Delete + + + +); + +// Exercises every Figma "Content" type: two-line + media, tags, status dot, tabular +// numerics, colored delta, monospace id, two-line time, and a trailing actions column. +const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Service', + enableHiding: false, + meta: { isRowHeader: true }, + cell: (info) => ( + + {info.getValue().slice(0, 2).toUpperCase()} + + } + primary={info.getValue()} + secondary={info.row.original.description} + /> + ), + }, + { + id: 'environments', + header: 'Environments', + enableSorting: false, + cell: (info) => , + }, + { + accessorKey: 'healthLabel', + header: 'Health', + cell: (info) => ( + {info.getValue()} + ), + }, + { + accessorKey: 'requests', + header: 'Requests', + meta: { align: 'end' }, + cell: (info) => {info.getValue().toLocaleString()}, + }, + { + accessorKey: 'errorRate', + header: 'Error rate', + meta: { align: 'end' }, + cell: (info) => ()} invert />, + }, + { + accessorKey: 'p95', + header: 'p95', + meta: { align: 'end' }, + cell: (info) => {`${info.getValue()} ms`}, + }, + { + accessorKey: 'id', + header: 'ID', + cell: (info) => {info.getValue()}, + }, + { + accessorKey: 'lastSeenDate', + header: 'Last seen', + cell: (info) => ( + ()} secondary={info.row.original.lastSeenTime} /> + ), + }, + { + id: 'actions', + header: '', + enableSorting: false, + enableHiding: false, + meta: { align: 'end', enableReorder: false }, + cell: () => , + }, +]; + +// --------------------------------------------------------------------------- +// Spans/traces list — matched to o11y "Screen designs" node 2829-54659 (Spans tab): +// Content · Service · Resource name · Span ID · Parent Span ID · Secure Session ID · Timestamp, +// single-line content with a hover-revealed "Open", `Empty` placeholders, a session chip. +// --------------------------------------------------------------------------- + +type SpanRow = { + id: string; // unique row id (span ids repeat in the design) + content: string; + service: string; + resource: string; + spanId: string; + parentSpanId: string | null; + sessionId: string | null; + timestamp: string; +}; + +const SESSION = 'aBCd5EFGh12345GHI'; +const spanRows: SpanRow[] = [ + { + id: 'r1', + content: 'graphql.field.pushTransactionMetrics', + service: 'public-graph', + resource: 'worker.kafka.datasync.consumer', + spanId: '55489g341034dc84', + parentSpanId: null, + sessionId: SESSION, + timestamp: '9/28/25 02:00AM', + }, + { + id: 'r2', + content: 'graphql.field.resolveViewerRoles', + service: 'public-graph', + resource: 'worker.kafka.datasync.consumer', + spanId: '33369c138040cd62', + parentSpanId: '55489g341034dc84', + sessionId: 'oVob7AEYqsTP1whOp', + timestamp: '9/26/25 00:00AM', + }, + { + id: 'r3', + content: 'graphql.field.pushTransactionMetrics', + service: 'public-graph', + resource: 'worker.kafka.datasync.consumer', + spanId: '55489g341034dc84', + parentSpanId: null, + sessionId: SESSION, + timestamp: '9/28/25 02:00AM', + }, + { + id: 'r4', + content: 'graphql.field.pushTransactionMetrics', + service: 'public-graph', + resource: 'worker.kafka.datasync.consumer', + spanId: '55489g341034dc84', + parentSpanId: null, + sessionId: SESSION, + timestamp: '9/28/25 02:00AM', + }, + { + id: 'r5', + content: 'graphql.field.pushTransactionMetrics', + service: 'public-graph', + resource: 'worker.kafka.datasync.consumer', + spanId: '55489g341034dc84', + parentSpanId: null, + sessionId: SESSION, + timestamp: '9/28/25 02:00AM', + }, + { + id: 'r6', + content: 'graphql.field.pushTransactionMetrics', + service: 'public-graph', + resource: 'worker.kafka.datasync.consumer', + spanId: '55489g341034dc84', + parentSpanId: null, + sessionId: SESSION, + timestamp: '9/28/25 02:00AM', + }, + { + id: 'r7', + content: 'graphql.field.pushTransactionMetrics', + service: 'public-graph', + resource: 'worker.kafka.datasync.consumer', + spanId: '55489g341034dc84', + parentSpanId: null, + sessionId: SESSION, + timestamp: '9/28/25 02:00AM', + }, + { + id: 'r8', + content: 'graphql.field.pushTransactionMetrics', + service: 'public-graph', + resource: 'worker.kafka.datasync.consumer', + spanId: '55489g341034dc84', + parentSpanId: null, + sessionId: SESSION, + timestamp: '9/28/25 02:00AM', + }, + { + id: 'r9', + content: 'http.server.request GET /flags', + service: 'api-gateway', + resource: 'GET /api/v2/flags', + spanId: '77a10b930fce21', + parentSpanId: null, + sessionId: null, + timestamp: '9/28/25 02:00AM', + }, + { + id: 'r10', + content: 'db.query SELECT flags', + service: 'flags-store', + resource: 'postgres.flags', + spanId: '90ff2c7715ab04', + parentSpanId: '77a10b930fce21', + sessionId: null, + timestamp: '9/28/25 02:00AM', + }, +]; + +/** Muted "Empty" placeholder for null cell values (Figma References/empty state). */ +const EmptyValue = () => Empty; + +/** Secure Session ID chip — an LP `Tag` with a session (replay) icon. */ +const SessionChip = ({ id }: { id: string }) => ( + + + + {id} + + + +); + +const meta: Meta = { + component: DataTable, + title: 'Components/Collections/DataTable', + parameters: { + figma: { + design: + 'https://www.figma.com/design/98HKKXL2dTle29ikJ3tzk7/%F0%9F%9A%80-LaunchPad?node-id=23034-34697', + }, + }, +}; + +export default meta; + +type Story = StoryObj>; + +/** Kitchen sink: content types + search, sort, column visibility, column reorder, row actions. */ +export const Overview: Story = { + render: (args) => {...args} data={data} columns={columns} />, + args: { 'aria-label': 'Services', enableColumnReorder: true }, +}; + +/** + * Traces "Spans" list (observability), matched to the o11y screen design (node 2829-54659): + * single-line Content with an "Open" button revealed on row hover/selection, `Empty` + * placeholders, a Secure Session ID chip, and click-a-row-to-open (single-select + row + * highlight). The surrounding page chrome (query bar, histogram, Overview/Spans tabs) is + * app-level and out of scope for the component. + */ +export const TracesList: StoryObj = { + render: () => { + const [openedId, setOpenedId] = useState('r2'); + const opened = spanRows.find((r) => r.id === openedId) ?? null; + + const spanColumns: ColumnDef[] = [ + { + accessorKey: 'content', + header: 'Content', + enableHiding: false, + meta: { isRowHeader: true }, + cell: (info) => ( + + + {info.getValue()} + + + + + + ), + }, + { accessorKey: 'service', header: 'Service' }, + { accessorKey: 'resource', header: 'Resource name' }, + { + accessorKey: 'spanId', + header: 'Span ID', + cell: (info) => {info.getValue()}, + }, + { + accessorKey: 'parentSpanId', + header: 'Parent Span ID', + cell: (info) => { + const value = info.getValue(); + return value ? {value} : ; + }, + }, + { + accessorKey: 'sessionId', + header: 'Secure Session ID', + enableSorting: false, + cell: (info) => { + const value = info.getValue(); + return value ? : ; + }, + }, + { accessorKey: 'timestamp', header: 'Timestamp' }, + ]; + + return ( +
+ {/* Reveal each row's "Open" button only on hover / when selected, per the design. */} + + + aria-label="Spans" + data={spanRows} + columns={spanColumns} + getRowId={(r) => r.id} + enableGlobalFilter={false} + selectionMode="single" + selectionBehavior="replace" + selectedKeys={openedId ? new Set([openedId]) : new Set()} + onSelectionChange={(keys) => { + if (keys !== 'all') { + const first = [...keys][0]; + setOpenedId(first != null ? String(first) : null); + } + }} + /> + {opened && ( +
+ Opened{' '} + + {opened.content} + {' '} + · span {opened.spanId} +
+ )} +
+ ); + }, +}; + +/** Row selection (RAC injects the select-all + per-row checkbox column). */ +export const WithSelection: Story = { + render: (args) => {...args} data={data} columns={columns} />, + args: { 'aria-label': 'Services', selectionMode: 'multiple' }, +}; + +/** All three Figma densities, stacked for comparison. */ +export const Density: Story = { + render: () => ( +
+ {(['default', 'compact', 'tight'] as const).map((density) => ( +
+

+ {density} +

+ + aria-label={`Services (${density})`} + data={data.slice(0, 4)} + columns={columns} + density={density} + enableGlobalFilter={false} + enableColumnVisibility={false} + /> +
+ ))} +
+ ), +}; + +/** Right-aligned numeric columns (tabular figures) with sorting. */ +export const NumericColumns: Story = { + render: (args) => ( + {...args} data={data} columns={columns} enableColumnResize /> + ), + args: { 'aria-label': 'Services' }, +}; + +/** Empty state. */ +export const Empty: Story = { + render: (args) => {...args} data={[]} columns={columns} />, + args: { 'aria-label': 'Services' }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc9fa51de..b0b6e11c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: '@react-aria/live-announcer': specifier: 3.4.4 version: 3.4.4 + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) class-variance-authority: specifier: 0.7.0 version: 0.7.0 @@ -2662,6 +2665,17 @@ packages: '@swc/types@0.1.21': resolution: {integrity: sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==} + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -3776,7 +3790,7 @@ packages: git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -5045,6 +5059,7 @@ packages: rolldown-vite@6.3.16: resolution: {integrity: sha512-aXSghipLk+1W3ku4JdZPAYH9io6m6yi4mEzmuhXZ3M+68TTJyDiXeWN8C13N7ClvwTN3Ckc/9nUpbWuYbbB9eA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + deprecated: Use 7.3.1 for migration purposes. For the most recent updates, migrate to Vite 8 once you're ready. hasBin: true peerDependencies: '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 @@ -8175,6 +8190,14 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tanstack/react-table@8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 diff --git a/spikes/launchpad-datatable/SPEC.md b/spikes/launchpad-datatable/SPEC.md new file mode 100644 index 000000000..e3c9acfa7 --- /dev/null +++ b/spikes/launchpad-datatable/SPEC.md @@ -0,0 +1,356 @@ +# Spike: Unified LaunchPad `DataTable` (RAC + TanStack v8) + +**Branch:** `spike/launchpad-datatable` (launchpad-ui) +**Status:** iteration 4 — real observability AlertsTable migrated onto `DataTable` (AlertsTableV2) and verified in gonfalon Storybook. Deferred: column reorder, size/config persistence, lazy activity loading (see §12). +**Owner:** Chris Schmitz +**Started:** 2026-07-09 + +> Local spike doc — not a Jira ticket. Tracks the problem, the target architecture, +> the API, and living progress. Verify every change in Storybook via Chrome. + +--- + +## 1. Background + +Discussion in `#launchpad-design-system` (2026-07-07/08) converged on a single goal: +**one high-quality, unified data table used everywhere in Gonfalon.** + +- Observability shipped its own table (`@gonfalon/observability-ui/components/Table`, + used by `AlertsTable` and other o11y surfaces). It is a `Box`/`div` compound component + on observability-ui's own `vars` tokens — **not React Aria, not LaunchPad design tokens** + (it does pull in a few LP icons/components). Built under Highlight's "ship fast" remit; + not a deliberate divergence, but the last major table outside LaunchPad. +- The o11y team committed to owning table consolidation next quarter, with the unified + LaunchPad component as a requirement. +- Design already has a **global table design system built and approved in Figma** + (semantics/tokens ready). +- Everyone agreed the initial build is "Claude-shaped" — a timeboxed, AI-assisted spike: + give Claude the target table as requirements + LP docs/architecture, build the component, + prove it out by replacing the o11y table, throw it away if it doesn't hold up. + +### Proposed technical shape (from the thread, Zakk V.) + +> Export a `DataTable` abstraction from LP composed on the base RAC `Table`. Internally +> `DataTable` bundles **TanStack Table (v8)** as the headless provider for functionality +> beyond RAC, **defaulting to RAC's built-ins where compatibility issues could arise +> (Selection, Sorting)**. Use RAC's drag-and-drop hooks natively. Reference the RAC/TanStack +> v8 CodeSandbox + TanStack's "Copy Table Prompt" output. + +## 2. Current state (verified in code) + +| Piece | Where | Notes | +|-------|-------|-------| +| Legacy LP table | `@launchpad-ui/table` (`packages/table`) | Plain `TableHead/Row/Cell`. Older, non-RAC. | +| RAC base table | `@launchpad-ui/components` → `Table.tsx` | Thin wrapper over `react-aria-components@1.13.0`. Built-in **sort UI, selection, row DnD, column resize**. Uses `--lp-*` tokens + CSS modules. **No higher-level data-table abstraction.** | +| TanStack | — | Not previously a dep. Added `@tanstack/react-table@^8` to `@launchpad-ui/components` for this spike. | +| o11y one-off | `gonfalon packages/observability-ui/src/components/Table` | Div/`Box` compound on observability `vars` tokens. `AlertsTable` layers `@dnd-kit` column reorder + custom column-management popover + persisted config + search on top. | + +**Gap:** LP has strong table *primitives* but no component that takes declarative +`columns` + `data` and provides freeform search, filtering, column visibility/ordering, +and grouping out of the box. That's the `DataTable` abstraction. + +## 3. Goals / non-goals + +**Goals (this spike)** +- Prove the RAC + TanStack v8 composition holds up as a real, ergonomic component. +- `DataTable` in `@launchpad-ui/components`: declarative `columns` (TanStack + `ColumnDef`) + `data`, LP tokens/styling, RAC semantics + a11y. +- Core data-heavy interactions working end to end in Storybook: **global search, sorting, + row selection, column visibility, column resize**. +- Storybook stories, verified in Chrome. + +**Non-goals (defer / follow-up)** +- Full o11y `AlertsTable` migration (separate follow-up; the real proof-out). +- Virtualization, pagination, server-side data, grouping/aggregation, faceted filters. +- Retiring `@launchpad-ui/table` or the o11y table. + +## 4. Target architecture — division of labor + +Faithful to the thread: **RAC owns interaction/semantics; TanStack owns the data model.** + +| Concern | Owner | Mechanism | +|---------|-------|-----------| +| Markup, keyboard nav, a11y | **RAC** | LP `Table/TableHeader/TableBody/Row/Cell/Column` | +| Selection | **RAC** | `selectionMode` + `selectedKeys`/`onSelectionChange` (RAC injects the checkbox column) | +| Sort *interaction/UI* | **RAC** | `` + `sortDescriptor`/`onSortChange` | +| Sort *model* | **TanStack** | `sorting` state → `getSortedRowModel()` (RAC descriptor ⇄ TanStack state bridge) | +| Row drag-and-drop | **RAC** | `useDragAndDrop` hooks (native) | +| Column resize | **RAC** | `ResizableTableContainer` + `ColumnResizer` | +| Freeform / global search | **TanStack** | `globalFilter` state → `getFilteredRowModel()` | +| Column filters | **TanStack** | `columnFilters` (follow-up) | +| Column visibility | **TanStack** | `columnVisibility` state + LP `Menu` (multi-select) toolbar control | +| Column ordering | **TanStack** | `columnOrder` state (UI: follow-up) | + +**Bridge rules** +- RAC `Row` key = TanStack `row.id`; RAC `Column` id = TanStack `column.id`. +- Only *data* columns/cells are rendered from TanStack; RAC auto-injects the + selection/drag columns and cells, so counts line up. +- Cell + header content rendered with TanStack `flexRender`. + +## 5. Proposed API (spike) + +```tsx +[] + getRowId={(r) => r.id} + selectionMode="multiple" // optional, RAC + enableGlobalFilter // toolbar search (default on) + enableColumnVisibility // toolbar column menu (default on) + enableColumnResize // wraps in ResizableTableContainer + onSelectionChange={...} +/> +``` + +- `columns`: standard TanStack `ColumnDef` (`accessorKey`/`accessorFn`, `header`, `cell`, + `enableSorting`, `enableHiding`, `size`, `meta.isRowHeader`). Matches "Copy Table Prompt". +- Toolbar is opt-out; when both search + column-visibility are off, `DataTable` renders + just the table. + +## 6. Acceptance criteria + +- [x] `DataTable` exported from `@launchpad-ui/components`, built on RAC `Table` + TanStack v8. +- [x] Declarative `columns` + `data`; LP tokens; no hardcoded colors. +- [x] Global search filters rows (TanStack). *(verified: "Traces" → 8→3 rows)* +- [x] Sorting works via RAC column UI, sorted by TanStack model. *(verified: Name asc/desc)* +- [x] Row selection via RAC (checkbox column), selection surfaced to consumer. *(verified: 9 checkboxes = 8 rows + select-all)* +- [x] Column visibility toggled from a toolbar menu (TanStack state). *(verified: hide Product → 4→3 columns, no crash)* +- [x] Column resize via RAC. *(Resizable story wraps in `ResizableTableContainer`)* +- [x] Storybook stories cover the above; verified in Chrome (screenshots in this dir). +- [x] Spike writeup (§9). Full o11y `AlertsTable` migration is the follow-up proof-out. +- [x] `pnpm typecheck` clean (whole repo); `biome check` clean on new files. + +## 7. Open questions + +- Column-header **reorder DnD** (o11y has it via @dnd-kit): RAC has no built-in column DnD. + Drive via TanStack `columnOrder` + custom drag, or accept menu-based reorder? (follow-up) +- Do we fold search/column-management **into** `DataTable`, or expose headless hooks so + product surfaces compose their own toolbars? (spike: built-in, opt-out) +- Where does this live long-term — `@launchpad-ui/components` or a dedicated `@launchpad-ui/data-table`? + +## 9. Spike findings / writeup + +**Does the RAC + TanStack v8 composition hold up? — Yes.** A ~230-line `DataTable` +delivers declarative columns+data with freeform search, sorting, selection, column +visibility, and resize, all on LP tokens and RAC semantics. The division of labor is +clean: TanStack owns the row model (filter/sort/visibility), RAC owns rendering, +selection, sort UI, and DnD. Full-repo `tsc` and `biome` pass; every interaction +verified headless in Chrome. + +**What the build proved** +- LP already had the hard part — a RAC `Table` with sort/selection/DnD/resize built in. + The missing piece was genuinely just the *headless data layer + toolbar*, which is + exactly the small, well-contained thing TanStack is for. +- Sort as a **hybrid** works well: RAC renders the caret UI and emits a `SortDescriptor`; + we translate to TanStack `sorting` state and let `getSortedRowModel()` do the ordering. + Faithful to "prefer RAC's built-in for Sorting" while TanStack owns the model. +- Selection stays **pure RAC** (`selectionMode` → RAC injects the checkbox column). We only + render *data* columns/cells from TanStack; RAC prepends selection/drag — counts line up. + +**The one real gotcha (documented in code)** +- RAC memoizes dynamic-collection rows by stable `row.id`. Toggling column visibility + updates the statically-mapped header but leaves *cached* cells behind → RAC throws + `Cell count must match column count`. Fix: pass RAC's `dependencies` prop a value that + changes with the visible columns. It must be **constant size** (RAC spreads it into a + `useMemo` deps array), so a single joined key string (`name|status|updated`), not the id + array itself. This is the key integration lesson for anyone extending the pattern. + +**What's needed to make it *the* Gonfalon table (follow-ups)** +1. **Prove-out migration:** re-implement o11y `AlertsTable` on `DataTable` (column + management popover → built-in visibility menu; `@dnd-kit` column reorder → TanStack + `columnOrder`; persisted config; status sort; row actions; activity sparkline). This is + the real test and the retirement path for `@gonfalon/observability-ui/components/Table`. +2. **Column reorder DnD:** RAC has no built-in column-header DnD. Decide: TanStack + `columnOrder` + a custom header drag, vs. menu-based reorder. +3. **Scale:** virtualization + pagination for large datasets (o11y tables get big). +4. **Filtering beyond search:** per-column/faceted filters (`columnFilters`). +5. **API shape:** built-in toolbar vs. headless hooks so surfaces compose their own + toolbars; and whether this lives in `@launchpad-ui/components` or a dedicated package. +6. **Design sign-off** against the approved Figma table system; Meticulous baseline. + +**Recommended path:** land `DataTable` in LP behind the existing RAC `Table` primitives, +then migrate tables newest/simplest-first, o11y `AlertsTable` as the flagship. Migrations +are Claude-shaped once the component and one reference migration exist. + +## 10. Figma comparison (actually checked, 2026-07-09) + +Compared against the approved **"Table BETA"** page (`node-id=23034-34697`), specifically +the assembled `Table [BETA]` preset (`23047:41619`), `Header/Regular` (`23047:41302`), and +its `get_variable_defs`. + +**Aligned (foundation is right):** the design's own variables ARE the LP token family my +component inherits via the RAC `Table` — `--lp-spacing-300` = 8px cell padding, +`--lp-color-border-ui-primary` #d8dde3, `--lp-color-bg-ui-secondary` #f7f9fb header fill, +`body/2/semibold` (13/20) headers, `label/1/medium` + `small/1/medium` cell text. So this is +the correct design system, not a divergent one — same conclusion the RAC primitive gave us. + +**Gaps — status after iteration 2:** +1. **Density** — ✅ CLOSED. `density` prop (`default`/`compact`/`tight`) mapped to LP + spacing tokens (8 / 4 / 0), verified across three stacked tables. +2. **Cell content types** — ✅ CLOSED (helpers in `DataTableCells.tsx`): `TwoLineCell` + (+ optional leading media → Text/Composition), `NumericValue` (tabular), + `DeltaValue` (signed, tabular, success/error/neutral, `invert` for lower-is-better), + `StatusCell` (semantic dot + label → Binary/status), `TagCell` (composes the LP + `TagGroup`/`Tag` DS primitive → References), `MonoValue` (code font → Monotype). + Time/Currency are `TwoLineCell`/`NumericValue` usages. Remaining Figma content not yet + built: sparkline, inline toggle, mini bar (References extras) — low priority. +3. **Header treatment** — ✅ matched to the o11y design (scoped to `.root`, no base fork): + muted `text-ui-secondary`, **`label/1/medium`** weight (medium, not the base's semibold), + a subtle **`bg-ui-secondary`** fill, and a **`border-ui-primary`** underline; row dividers + also recolored to `border-ui-primary` (#d8dde3). Column-settings control is now a **`⋯` + (more-horiz) icon button** pinned top-right. **Still divergent (inherited from base LP RAC + `Table`, would require changing the shared `Table.tsx`):** the sort icon is `caret-up/down` + where Figma uses a **down-arrow**, and there's no per-column dropdown menu — flagged to + design/LP rather than forked here. +4. **Right-aligned numeric** — ✅ CLOSED. Column `meta.align: 'end'` right-aligns header + + cells; numeric helpers use tabular figures. Verified in the NumericColumns story. +5. **Trailing actions "…" column** — ✅ demonstrated. Modeled as a normal non-sortable, + non-hideable column with an `IconButton` (`more-horiz`) + `Menu` cell in the Overview + story. Not special API — the standard column pattern. + +**Honest verdict:** the component now realizes most of the Table BETA design — right tokens, +density, content-type cells, numeric alignment, muted headers — and is verified in Storybook +across 6 stories (Overview, WithSelection, Density, NumericColumns, Empty). The remaining +true gap is the **header sort-icon (caret vs down-arrow) + per-column menu**, which lives in +the shared LP RAC `Table` and should be a design/LP decision, not a DataTable-local fork. The +o11y `AlertsTable` migration remains the real end-to-end proof-out. + +## 11. Row interaction — click-row-to-open (traces list) + +The primary interaction for a data-heavy list (e.g. observability traces) is **click a row to +open its detail**. Supported two ways on `DataTable`: +- `onRowAction(id)` — RAC row activation (click / Enter); RAC suppresses it when the press + lands on an interactive cell (checkbox, actions menu). Good for navigation. +- `selectionMode="single"` + `selectionBehavior="replace"` + controlled `selectedKeys` — + whole-row select with a **highlight** and no checkbox; drives a list/detail layout. + +`TracesList` story uses the second: left = traces list (mono trace id, service, status dot, +tabular duration, start time); right = trace detail with a **span waterfall** (bars positioned +by offset/width, colored per service, indented by depth). Clicking a row highlights it and +swaps the detail — verified headless (`v3-traces-*.png`). Also added scoped hover/selected row +backgrounds (the base LP `Table` styles only cursor + focus ring). + +**✅ Reconciled against o11y "Screen designs" `2829-54659` (Spans tab).** Reworked the story +to match: columns are **Content · Service · Resource name · Span ID · Parent Span ID · Secure +Session ID · Timestamp**; Content is single-line with an **"Open" button revealed on row +hover/selection**; null cells show a muted **`Empty`** placeholder; Secure Session ID is a +chip (LP `Tag` + session icon); Span/Parent IDs are monospace; column-settings pinned +top-right. Dropped the earlier invented waterfall/status/duration columns. Verified headless +(`v4-traces-*.png`): 7 columns, `Empty` present, row click updates the opened-span banner, +zero console errors. + +Header + borders now matched to the row/header tokens read from the design (header +`label/1/medium` + `bg-ui-secondary` + `border-ui-primary` underline; row dividers +`border-ui-primary`); column-settings is now a `⋯` icon button. Remaining deltas: (a) the +surrounding page chrome — query-DSL bar, histogram, Overview/Spans tabs, time picker +— is app-level and out of scope for a `DataTable` component story. **Note:** the session chip +nests an interactive `TagGroup` (role=grid) inside a cell; a static read-only badge would be +better for dense cells — LP has no non-interactive Tag/Badge today (follow-up). + +## 12. Proof-out: observability AlertsTable migration (in gonfalon) + +The real end-to-end test — replace the bespoke observability alerts table with `DataTable`. + +**Where** (gonfalon `spike/launchpad-datatable`): +- `packages/observability/src/components/DataTable/` — the LP `DataTable` + cells **vendored** + (imports rewired to published `@launchpad-ui/components@0.19.1` + `@tanstack/react-table@8.21.3`, + both already gonfalon deps). Marked TEMPORARY — deleted once LP publishes `DataTable`. Placed + in `packages/observability` (not `observability-ui`, which is maintenance-mode: no new + components/exports, vanilla-extract only). Added a `defaultSorting` prop. +- `packages/observability/src/pages/Alerts/components/AlertsTable/AlertsTableV2.tsx` — the alerts + table re-implemented on `DataTable`, **reusing the real cell components** (`AlertStatusBadge`, + `AlertDestinationTags`, `AlertActivitySparkline`, `AlertActionsMenu`, adaptive-triggers tags). + Built alongside the production `AlertsTable` (non-destructive). +- `AlertsTableV2.stories.tsx` — mock data (5 alerts, varied statuses), provider stack mirrored + from `TracePanel.stories.tsx` (memory data router + `VegaProjectProvider` + `ObservabilityQueryProvider`). + +**Covered & verified** (gonfalon Storybook `:6006`, headless Chrome — `v5-alerts-v2*.png`): +declarative columns (name/status/last-update/triggers/destinations/activity/actions); freeform +search (name); **status-priority sort** (Alerting→Error→Enabled→Disabled, on-fire first) via a +TanStack `sortingFn` + `defaultSorting`; alphabetical name sort; column show/hide via the ⋯ menu; +all the real cells render; row click-to-open. `tsc` clean for the new files (37 remaining errors +are pre-existing `*-test` files); only benign LaunchDarkly-SDK-in-Storybook console warnings. + +**Column drag-reorder — ✅ DONE.** Added `enableColumnReorder` to `DataTable`: a grip handle +(revealed on header hover) drives TanStack `columnOrder` via lightweight **HTML5 drag** — no +`@dnd-kit` dependency, works identically in the LP source and the vendored copy. Headers *and* +cells reorder together; a column pins with `meta.enableReorder: false` (AlertsTableV2 pins the +actions column). Verified in gonfalon Storybook (dragging Status past Last-status-update swaps +both). RAC has no native column DnD, so this is our own layer. *Follow-up:* keyboard reorder +(the grip is `role="button"` but mouse-only today — flagged with a `biome-ignore` on the drop +zone). Also mirrored back into the canonical LP `DataTable` (+ a `defaultSorting` prop that had +drifted), so both copies are in sync. + +**Deferred (documented, not yet mapped):** +- **Per-column resize + size/order/visibility persistence** (prod persists to localStorage). +- **Lazy per-row activity loading** (prod fetches sparklines only for on-screen rows via an + intersection observer) — here activity is supplied up-front; `DataTable` needs a row-visible + hook to preserve this. + +**Takeaway:** `DataTable` hosts the real alerts table's rich cells cleanly and replaces the +bespoke `observability-ui/Table` + `@dnd-kit` + `CustomColumnPopover` stack for the core cases. +The deferred items are the concrete backlog to make it a full drop-in replacement. + +## 8. References + +- Slack: `#launchpad-design-system` thread (2026-07-08, ts 1783489510.817879) +- Figma: approved global table design system (node 23034-34697) +- o11y table: `gonfalon packages/observability-ui/src/components/Table/`, `.../Alerts/components/AlertsTable/` +- LP RAC table: `launchpad-ui packages/components/src/Table.tsx` +- RAC + TanStack v8 CodeSandbox (from thread); TanStack "Copy Table Prompt" + +--- + +## Progress log + +- **2026-07-10** — Added **column drag-reorder** to `DataTable` (`enableColumnReorder`): grip + handle → TanStack `columnOrder`, dep-free HTML5 drag, `meta.enableReorder:false` to pin. + Enabled on AlertsTableV2 (actions pinned); verified in gonfalon Storybook (headers+cells + reorder together). Mirrored to the canonical LP source + synced a drifted `defaultSorting` + prop; biome/tsc clean in both. Keyboard reorder is the remaining follow-up. + + +- **2026-07-09** — Spike kicked off. Branch created. Repo recon done (Storybook 9, pnpm+nx, + cva + CSS modules + `--lp-*` tokens, RAC Table already ships sort/selection/DnD/resize). + Added `@tanstack/react-table@8.21.3` to `@launchpad-ui/components`. Spec written. +- **2026-07-09** — Built `DataTable.tsx` + `DataTable.module.css` + barrel exports + + `DataTable.stories.tsx` (Example / WithSelection / Resizable, alerts-shaped data). + Ran Storybook, drove all 3 stories headless in Chrome. Verified initial render (8 rows), + global search (→3), sort asc/desc, selection (checkbox column), column visibility. + Hit + fixed the RAC dynamic-collection cache invariant via `dependencies` (see §9). + `pnpm typecheck` (whole repo) and `biome check` both clean. Screenshots: `verify-*.png`. +- **2026-07-09** — Actually compared against Figma "Table BETA" (`23034-34697`) via the + Figma MCP (screenshots + `get_variable_defs`). Foundation/tokens align; logged concrete + gaps (density, cell content types, header sort-icon/color, numeric align, actions col) + in §10. Corrected the record: this is a correct-foundation primitive, not a full + realization of the design. +- **2026-07-09** — Iteration 2: closed most §10 gaps. Added `density` prop + `meta.align` + right-align to `DataTable`; scoped muted header color; built `DataTableCells.tsx` + (TwoLineCell, NumericValue, DeltaValue+invert, StatusCell, TagCell over LP `Tag`, + MonoValue). Rewrote stories into a content-type kitchen sink + Density/Numeric/Selection/ + Empty. Fixed a real `TagList` name collision with LP's existing `TagGroup` export (renamed + to `TagCell`, now composes the DS `Tag`). Verified all 6 stories headless in Chrome (zero + console errors); `pnpm typecheck` + `biome` clean. Screenshots: `v2-*.png`. Remaining true + gap: header sort-icon/per-column menu lives in the shared LP RAC `Table` → raise w/ design. +- **2026-07-09** — Iteration 3: added `onRowAction` + `selectionBehavior` passthrough and a + scoped hover/selected row highlight; built the `TracesList` story (list/detail with a span + waterfall) for the click-row-to-open-a-trace pattern. Verified headless in Chrome (row click + swaps the detail; zero console errors); `pnpm typecheck` + `biome` clean (`v3-traces-*.png`). + Blocked on reconciling visuals vs o11y "Screen designs" 2829-54659 — need it active in Figma. +- **2026-07-09** — Migrated the real observability AlertsTable onto `DataTable` in gonfalon + (branch `spike/launchpad-datatable`): vendored DataTable+cells into `packages/observability`, + built `AlertsTableV2` reusing the production cell components + a Storybook story. Verified in + gonfalon Storybook `:6006` (headless): columns, name search, status-priority + name sort, + column show/hide, all real cells render, row click-to-open. `tsc` clean for new files. See §12 + for covered vs deferred (reorder / persistence / lazy activity). +- **2026-07-09** — Pulled o11y "Screen designs" `2829-54659` (Spans tab) via Figma MCP and + reworked `TracesList` to match: real columns, single-line Content + hover-revealed "Open", + `Empty` placeholders, session chip, mono IDs, column-settings pinned right (new `columnMenu` + style). Dropped the invented waterfall. Verified headless (`v4-traces-*.png`); `pnpm + typecheck` + `biome` clean; zero console errors. +- **2026-07-09** — Matched header + borders to the o11y design (read row/header tokens from + `2829-54705`/`54706`): header `label/1/medium` + `bg-ui-secondary` + `border-ui-primary` + underline, row dividers `border-ui-primary`; swapped the "Columns" text button for a `⋯` + (more-horiz) icon button (dropped the now-unused `Button` import). Scoped to `.root` — the + shared LP `Table` is untouched. Verified traces + overview headless; typecheck + biome clean.