Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7277b95
docs: spec for components SP1 — the contract, and Button / IconButton
blove Sep 5, 2026
211343b
docs(spec): components SP1 — ghost/link variants, and `site` not `role`
blove Sep 5, 2026
cf68a5b
docs: implementation plan for components SP1
blove Sep 5, 2026
700904a
feat(react): PretableButton and PretableIconButton
blove Sep 5, 2026
4015cde
fix(react): IconButton warns on an empty accessible name; pin the con…
blove Sep 5, 2026
1757568
docs(spec): the two push-buttons share one file
blove Sep 5, 2026
dc18d98
fix(react): the empty-name guard tolerates a missing aria-label
blove Sep 5, 2026
dc27518
test(react): pin the Button/IconButton type contract
blove Sep 5, 2026
11a8021
feat(react): the components context
blove Sep 5, 2026
5ee107f
test(react): the site union is pinned open AND built-in; anchor the h…
blove Sep 5, 2026
9e2fef0
test(react): pin the resolved-components memo with a changed-slot case
blove Sep 5, 2026
f0ecc76
feat(react): the components prop on the surface and the preset
blove Sep 5, 2026
cb20aa2
feat(ui): the kit button rules
blove Sep 5, 2026
22d3496
test(ui): a push-button site rule declares only what is its own
blove Sep 5, 2026
aa192e1
refactor(react): the five ghost actions render the kit Button
blove Sep 5, 2026
f58fad6
refactor(react): Clear and Reset columns render the kit Button, link …
blove Sep 5, 2026
56de062
test(react): the ref test proves the replacement rendered; the memo t…
blove Sep 5, 2026
1789027
test(ui): the button guards check declarations, not selector counts
blove Sep 5, 2026
d71641f
refactor(react): the six icon buttons render the kit IconButton
blove Sep 5, 2026
b276704
refactor(ui): the push-button site rules collapse onto the kit rules
blove Sep 5, 2026
32012e1
feat(react): export the kit buttons and the components slot types
blove Sep 5, 2026
f934a2d
test(react): every migrated site is pinned to its kit component; revi…
blove Sep 5, 2026
13a5e89
fix(ui): chip-remove's dead type declarations go; site hovers guard :…
blove Sep 5, 2026
16c6bed
docs: the Components page, its live example, and the registered tables
blove Sep 5, 2026
15c7dfc
test: the portal read settles like its sibling; the hover guard check…
blove Sep 5, 2026
96bd87b
test(website): the components slot in a real browser
blove Sep 5, 2026
4a3908d
docs: the Components page says what the code does
blove Sep 5, 2026
548ca91
test(website): the ref claim is the focus-return line, and says so
blove Sep 5, 2026
5a61f82
fix(ui): the two portaled surfaces that host kit controls declare lin…
blove Sep 5, 2026
bce9744
chore: changeset for components SP1
blove Sep 5, 2026
9766c92
test(website): the sitemap has 51 entries with the Components page
blove Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/components-sp1-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@pretable/react": minor
"@pretable/ui": patch
---

The first two components of a kit the grid renders its own chrome from, and a
`components` prop to replace either with your own.

`PretableButton` (two looks: `ghost`, the 24px action with a hover tint, and
`link`, plain accent text) and `PretableIconButton` (an icon-only button whose
`aria-label` is a required prop — omitting the accessible name is now a
compile error, and an empty one warns in development). Both are always
`type="button"`, pass `className` and `style` straight through, forward their
`ref`, and carry `data-pretable-button` / `data-pretable-icon-button`,
`data-pretable-variant` (labelled buttons) and `data-pretable-site` for styling
through the grid's usual attributes-and-tokens channel.

`components={{ Button, IconButton }}` on `<PretableSurface>` and `<Pretable>`
replaces a component everywhere it appears — the tool panel, the header, the
group panel and the portalled filter dialog — and the replacement receives
exactly the props the built-in does, `site` included, so it can branch on where
in the grid it is. It must forward its `ref` (under React 18, `forwardRef`; under
React 19 a plain `ref` prop): the grid anchors menus on and returns focus to
that node.

The grid's twelve plain push-buttons now render from these. Each keeps its
original attribute (`data-pretable-filter-clear`, `data-pretable-tool-reset`,
…), so selectors and stylesheets keyed on them keep working; the shared look
moved from twelve site rules onto the component rules, and every site's
computed box, colours and focus ring were measured before and after — no pixel
moved. Three things did change on purpose: `Clear` and `Reset columns`, and the
funnel, column-menu and chip-remove icon buttons, now wear the product's focus
ring rather than the browser's; `Clear` and `Reset columns` carry the control
radius (visible only on that ring's corners); and the kit icon-button rule does
not set `position: relative` — the three grid buttons that enlarge their hit
area with a `::after` declare it themselves, so a consumer rendering an
absolutely-positioned child inside `PretableIconButton` should position the
button explicitly.

Two smaller notes. The set of built-in `site` names (`PretableBuiltInButtonSite`)
grows additively — a new grid button may add one without a major bump. And the
two portalled surfaces that host these controls, the filter dialog and the
column menu, now declare `line-height` alongside the family, size and colour
they already declared: `font: inherit` on a kit button had been pulling the
host page's line-height into `Clear` and every menu item for a consumer with no
CSS reset.
1 change: 1 addition & 0 deletions apps/website/app/docs/_nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const docsNav: DocsNavSection[] = [
title: "Cell presentations",
href: "/docs/grid/cell-presentations",
},
{ title: "Components", href: "/docs/grid/components" },
{
title: "Custom rendering",
href: "/docs/grid/custom-rendering",
Expand Down
85 changes: 85 additions & 0 deletions apps/website/app/fixtures/components/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use client";

import {
PretableSurface,
type PretableButtonComponent,
type PretableColumn,
type PretableIconButtonComponent,
} from "@pretable/react";
import { forwardRef } from "react";

/**
* Test fixture for `apps/website/e2e/components.spec.ts`.
*
* The unit suite (`components-override.test.tsx`) proves the components
* context resolves and reaches every site in jsdom. What only a real
* browser can prove is that a replacement lands inside a popover the grid
* PORTALS into `document.body` — the case the context exists for — and
* that the grid's own behaviour on a replaced icon button (the menu it
* anchors on the node, the focus it returns there) survives through the
* forwarded ref. Both slots below are replaced with components that mark
* themselves and record their `site`.
*
* Deliberately not part of the product surface; `fixtures/layout.tsx` keeps
* the route out of search engines.
*/

interface Row {
id: string;
name: string;
qty: number;
}

const ROWS: Row[] = [
{ id: "a", name: "Alpha", qty: 1 },
{ id: "b", name: "Bravo", qty: 2 },
{ id: "c", name: "Charlie", qty: 3 },
];

const COLUMNS: PretableColumn<Row>[] = [
{ id: "name", header: "Name", widthPx: 160, type: "text" },
{ id: "qty", header: "Qty", widthPx: 100, type: "number" },
];

const FixtureButton: PretableButtonComponent = forwardRef(
function FixtureButton({ site, variant, ...props }, ref) {
return (
<button
{...props}
ref={ref}
type="button"
data-fixture-button={site ?? ""}
data-fixture-variant={variant ?? "ghost"}
/>
);
},
);

const FixtureIconButton: PretableIconButtonComponent = forwardRef(
function FixtureIconButton({ site, ...props }, ref) {
return (
<button
{...props}
ref={ref}
type="button"
data-fixture-icon={site ?? ""}
/>
);
},
);

export default function ComponentsFixturePage() {
return (
<main style={{ padding: 24 }}>
<PretableSurface
ariaLabel="components-fixture"
columns={COLUMNS}
components={{ Button: FixtureButton, IconButton: FixtureIconButton }}
getRowId={(row) => row.id}
rows={ROWS}
toolPanel={{ defaultActiveSection: "columns" }}
viewportHeight={240}
/>
</main>
);
}
77 changes: 77 additions & 0 deletions apps/website/content/docs/grid/components.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
title: Components
description: "The kit components the grid renders its own chrome from — Button and IconButton — how to style them, and how to replace either with your own."
nav: Grid
---

`@pretable/react` renders its own controls from a small component kit, and
ships those components for you to use and to replace. This page covers the
first two: `PretableButton` and `PretableIconButton`. Here is a grid whose
every labelled button is the app's own:

<Example id="components-button" />

## The components

Both render `<button type="button">`, always — every grid button is one, and a
stray submit inside your `<form>` is a real bug class, so `type` is not a prop.
`className` and `style` pass straight through — the components set neither,
so yours is the whole value. Both forward their `ref` to the button node.

### Button

A labelled push-button in one of the grid's two looks.

| Prop | Type | Notes |
| --------- | -------------------- | ------------------------------------------------------------------------------------------- |
| `variant` | `"ghost" \| "link"` | `ghost` is the 24px action with a hover tint; `link` is plain accent text. Default `ghost`. |
| `site` | `PretableButtonSite` | Where in the grid the button is; lands as `data-pretable-site`. Open to your own names. |

### IconButton

An icon-only push-button. `aria-label` is **required** — it is the button's
only accessible name, and omitting it is a compile error rather than an
accessibility failure discovered later. An empty one warns in development.

| Prop | Type | Notes |
| ------------ | -------------------- | --------------------------------------------------------------- |
| `aria-label` | `string` | Required. The accessible name. |
| `site` | `PretableButtonSite` | Where in the grid the button is; lands as `data-pretable-site`. |

## Styling

The styling channel is the one the whole grid uses: attributes and tokens.
Every button carries `data-pretable-button` or `data-pretable-icon-button`; a
labelled button also carries `data-pretable-variant`, and either carries
`data-pretable-site` when the grid gave it one. The grid's own sites also
keep their original attribute (`data-pretable-filter-clear`,
`data-pretable-tool-reset`, …), so a selector you already wrote keeps matching.

```css
[data-pretable-button][data-pretable-variant="ghost"] {
--pretable-accent: rebeccapurple;
}
[data-pretable-site="tool-reset"] {
color: var(--pretable-text-error);
}
```

## Replacing a component

Pass `components` to `<PretableSurface>` (or `<Pretable>`) with one entry per
component type you want to replace. The replacement is used everywhere that
type appears — the tool panel and the portalled filter dialog for `Button`;
the header, the tool panel and the group panel for `IconButton` — and
receives exactly the props the built-in does, `site` included, so it can
branch on where it is.

| Prop | Type | Notes |
| ------------ | ----------------------------- | ------------------------------------------------------------------------------ |
| `Button` | `PretableButtonComponent` | Receives `PretableButtonProps`; must forward its `ref` to the button node. |
| `IconButton` | `PretableIconButtonComponent` | Receives `PretableIconButtonProps`; must forward its `ref` to the button node. |

Forwarding the `ref` is the one obligation on a replacement: the grid anchors
menus on the node and returns keyboard focus to it. Under React 18 that means
`forwardRef`; under React 19 a plain `ref` prop is enough. The set of built-in
`site` names is the `PretableBuiltInButtonSite` type and grows additively — a
new grid button may introduce a new site without a major bump.
2 changes: 1 addition & 1 deletion apps/website/content/docs/grid/pretable-component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Row identity is never derived from array position. `Person` above has a conventi

## Props

`columns`, `rows`, and `ariaLabel` are required, and so is `getRowId` unless the row has a conventional string or number `id` — `ariaLabel` is the grid's accessible name, so give it something a screen reader user would recognize as this table's contents (e.g. `"People directory"`, not a generic placeholder). The concise preset also forwards row activation and selection callbacks, row changes, clipboard hooks, tab behavior, messages, the optional selection column, and column width/order/pinning callbacks. Use [`<PretableSurface>`](/docs/grid/pretable-surface) for controlled query and interaction state, grouping controls, custom renderers, explicit row-model ownership, telemetry, or a custom viewport height.
`columns`, `rows`, and `ariaLabel` are required, and so is `getRowId` unless the row has a conventional string or number `id` — `ariaLabel` is the grid's accessible name, so give it something a screen reader user would recognize as this table's contents (e.g. `"People directory"`, not a generic placeholder). The concise preset also forwards row activation and selection callbacks, row changes, clipboard hooks, tab behavior, messages, the components slot, the optional selection column, and column width/order/pinning callbacks. Use [`<PretableSurface>`](/docs/grid/pretable-surface) for controlled query and interaction state, grouping controls, custom renderers, explicit row-model ownership, telemetry, or a custom viewport height.

## Server-side data

Expand Down
27 changes: 14 additions & 13 deletions apps/website/content/docs/grid/pretable-surface.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,20 @@ The modes are mutually exclusive: do not pass `rows` or `getRowId` with `model`.

## Configuration

| Area | Props |
| ------------------ | ---------------------------------------------------------------------------------- |
| Geometry | `viewportHeight`, `viewportStyle`, `overscan` |
| Rendering | `renderBodyCell`, `renderHeaderCell`, row/cell/header class and prop callbacks |
| Value presentation | `locale`; column `dateFormat`, `numberFormat`, `format`, and `formatAggregate` |
| Controlled UI | `state`, `onSelectionChange`, `onFocusChange` |
| Row selection | `rowSelectionColumn`, `state.rowSelection`, `onRowSelectionChange` — its own slice |
| Rows-mode query | the exact `query`, `onQueryChange` pair |
| Editing | rows mode: `onRowChange`; model mode: `beforeRowChange`; shared: `onPaste` |
| Clipboard | `onCopy`, `copyToClipboard`, `copyWithHeaders`, `messages` |
| Grouping | `groupPanel`, `groupColumn`, `hideGroupedColumns`, `initialExpansion` |
| Tool panel | `toolPanel` — on by default; see [Tool panel](/docs/grid/tool-panel) |
| Observation | `onGridReady`, `onTelemetryChange`, `onRejectedWriteChange` |
| Area | Props |
| ------------------ | ------------------------------------------------------------------------------------------ |
| Geometry | `viewportHeight`, `viewportStyle`, `overscan` |
| Rendering | `renderBodyCell`, `renderHeaderCell`, row/cell/header class and prop callbacks |
| Components | `components` — replace a kit component everywhere; see [Components](/docs/grid/components) |
| Value presentation | `locale`; column `dateFormat`, `numberFormat`, `format`, and `formatAggregate` |
| Controlled UI | `state`, `onSelectionChange`, `onFocusChange` |
| Row selection | `rowSelectionColumn`, `state.rowSelection`, `onRowSelectionChange` — its own slice |
| Rows-mode query | the exact `query`, `onQueryChange` pair |
| Editing | rows mode: `onRowChange`; model mode: `beforeRowChange`; shared: `onPaste` |
| Clipboard | `onCopy`, `copyToClipboard`, `copyWithHeaders`, `messages` |
| Grouping | `groupPanel`, `groupColumn`, `hideGroupedColumns`, `initialExpansion` |
| Tool panel | `toolPanel` — on by default; see [Tool panel](/docs/grid/tool-panel) |
| Observation | `onGridReady`, `onTelemetryChange`, `onRejectedWriteChange` |

Rows-mode edit callbacks are proposals: update your `rows` value and let the next prop reconcile. Explicit-model edits validate the batch and publish one row-model transaction.

Expand Down
28 changes: 28 additions & 0 deletions apps/website/content/examples/components-button/BrandButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { forwardRef } from "react";
import type { PretableButtonProps } from "@pretable/react";

/**
* The app's own button, standing in for every Button the grid renders. It
* receives exactly what pretable's does — `site` included — and forwards its
* ref, which is the one thing the grid asks of a replacement: menus anchor on
* the node, and focus returns to it.
*/
export const BrandButton = forwardRef<HTMLButtonElement, PretableButtonProps>(
function BrandButton({ site, variant, className, ...props }, ref) {
// One place treated differently: the reset is destructive, so it gets the
// app's danger styling. Everything else is the brand default.
const tone = site === "tool-reset" ? "danger" : (variant ?? "ghost");
return (
<button
{...props}
ref={ref}
type="button"
className={["brand-button", `brand-button--${tone}`, className]
.filter(Boolean)
.join(" ")}
/>
);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";

import { PretableSurface } from "@pretable/react";

import "./brand-button.css";

import { BrandButton } from "./BrandButton";
import { columns } from "./columns";
import { trades, type Trade } from "./data";

const VIEWPORT_HEIGHT = 260;

export function ComponentsGrid() {
return (
<div>
<p style={{ margin: "0 0 8px", fontSize: 13 }}>
Every labelled button below is <code>BrandButton</code>; the icon
buttons — the funnels, the ⋮ menus — are still pretable&apos;s, because
this demo replaces only <code>Button</code>. Reset columns is the
app&apos;s danger button (it branches on <code>site</code>); the Filters
section&apos;s <code>+ filter</code> / <code>+ group</code> are its
ghost buttons; a column funnel&apos;s dialog has its link-styled{" "}
<code>Clear</code>.
</p>
<PretableSurface<Trade>
ariaLabel="Trades"
columns={columns}
components={{ Button: BrandButton }}
getRowId={(row) => row.id}
rows={trades}
toolPanel={{ defaultActiveSection: "columns" }}
viewportHeight={VIEWPORT_HEIGHT}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.brand-button {
border: 0;
border-radius: 999px;
padding: 0 12px;
height: 26px;
font: inherit;
font-weight: 600;
cursor: pointer;
}

.brand-button--ghost {
background: #eef2ff;
color: #3730a3;
}

.brand-button--link {
background: transparent;
color: #3730a3;
padding: 0 4px;
}

.brand-button--danger {
background: #fee2e2;
color: #991b1b;
}

.brand-button:disabled {
opacity: 0.5;
cursor: default;
}
10 changes: 10 additions & 0 deletions apps/website/content/examples/components-button/columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { PretableColumn } from "@pretable/react";

import type { Trade } from "./data";

export const columns: PretableColumn<Trade>[] = [
{ id: "symbol", header: "Symbol", widthPx: 110, type: "text" },
{ id: "side", header: "Side", widthPx: 90, type: "enum" },
{ id: "qty", header: "Qty", widthPx: 90, type: "number" },
{ id: "price", header: "Price", widthPx: 110, type: "number" },
];
15 changes: 15 additions & 0 deletions apps/website/content/examples/components-button/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface Trade {
id: string;
symbol: string;
side: "Buy" | "Sell";
qty: number;
price: number;
}

export const trades: Trade[] = [
{ id: "t1", symbol: "AAPL", side: "Buy", qty: 100, price: 226.11 },
{ id: "t2", symbol: "MSFT", side: "Sell", qty: 40, price: 418.38 },
{ id: "t3", symbol: "NVDA", side: "Buy", qty: 25, price: 869.63 },
{ id: "t4", symbol: "AMZN", side: "Buy", qty: 60, price: 183.91 },
{ id: "t5", symbol: "META", side: "Sell", qty: 15, price: 509.62 },
];
5 changes: 5 additions & 0 deletions apps/website/content/examples/components-button/demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ComponentsGrid } from "./ComponentsGrid";

export default function Demo() {
return <ComponentsGrid />;
}
Loading