From 82d85300236192f0335e7ad75d6a5a7a060fe838 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Mon, 8 Jun 2026 20:59:58 +0700 Subject: [PATCH 01/16] Add Table component --- .../propel/src/components/table/index.tsx | 145 ++++++++++++++++++ .../src/components/table/table.stories.tsx | 105 +++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 packages/propel/src/components/table/index.tsx create mode 100644 packages/propel/src/components/table/table.stories.tsx diff --git a/packages/propel/src/components/table/index.tsx b/packages/propel/src/components/table/index.tsx new file mode 100644 index 00000000..3d617221 --- /dev/null +++ b/packages/propel/src/components/table/index.tsx @@ -0,0 +1,145 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { ChevronDown, ChevronsUpDown, ChevronUp } from "lucide-react"; +import * as React from "react"; + +// The whole table renders on `background/surface-1` with a subtle 1px divider +// between rows (Figma "Table" — header 38px, cells 38px, `px-4 py-2`). + +export type TableProps = Omit, "className" | "style">; + +/** + * Root ``. Compose with `TableHeader`, `TableBody`, `TableRow`, + * `TableHead`, and `TableCell`. Renders on `surface-1` with `text-13` body text. + */ +export function Table(props: TableProps) { + return ( +
+ ); +} + +export type TableHeaderProps = Omit, "className" | "style">; + +/** Header section (``). Holds a single `TableRow` of `TableHead` cells. */ +export function TableHeader(props: TableHeaderProps) { + return ; +} + +export type TableBodyProps = Omit, "className" | "style">; + +/** Body section (``). Holds the data `TableRow`s. */ +export function TableBody(props: TableBodyProps) { + return ; +} + +export type TableRowProps = Omit, "className" | "style">; + +/** + * A table row (``). Body rows draw a subtle bottom divider and tint on hover; + * the divider after the last row is hidden so the table closes cleanly. + */ +export function TableRow(props: TableRowProps) { + return ; +} + +// Header cells follow the Figma "Table header" component: 38px tall, `px-4 py-2`, +// `text-12` semibold. A plain header sits on `background/layer/1` with +// `text/secondary`; a sortable header switches to `surface-1` + `text/tertiary` +// and exposes a sort affordance. +const tableHeadVariants = cva("h-[38px] px-4 py-2 text-start align-middle text-12 font-semibold", { + variants: { + variant: { + default: "bg-layer-1 text-secondary", + sortable: "bg-surface-1 text-tertiary", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +/** Sort direction of a sortable `TableHead`. `none` shows the neutral affordance. */ +export type TableHeadSort = "asc" | "desc" | "none"; + +const sortIcon: Record = { + asc: ChevronUp, + desc: ChevronDown, + none: ChevronsUpDown, +}; + +const ariaSort: Record = { + asc: "ascending", + desc: "descending", + none: "none", +}; + +export type TableHeadProps = Omit, "className" | "style" | "aria-sort"> & + VariantProps & { + /** + * Visual treatment. `sortable` switches to the surface background and renders + * a clickable button with a sort chevron; set it whenever you pass `sort`. + */ + variant?: NonNullable["variant"]>; + /** + * Current sort state for a sortable header. Drives both the chevron icon + * (`asc`→up, `desc`→down, `none`→up/down) and the cell's `aria-sort`. + */ + sort?: TableHeadSort; + /** Click handler for the sort control; only used when the header is sortable. */ + onSort?: () => void; + }; + +/** + * A header cell (` + ); +} + +// Keeps the chevron a fixed 14px (Figma sort icon) with the muted icon color. +function ChevronGlyphSlot({ Glyph }: { Glyph: typeof ChevronsUpDown }) { + return ; +} + +export type TableCellProps = Omit, "className" | "style">; + +/** A data cell (` + ); +} diff --git a/packages/propel/src/components/table/table.stories.tsx b/packages/propel/src/components/table/table.stories.tsx new file mode 100644 index 00000000..0375152a --- /dev/null +++ b/packages/propel/src/components/table/table.stories.tsx @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import * as React from "react"; +import { expect, userEvent } from "storybook/test"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + type TableHeadSort, +} from "./index"; + +const meta = { + title: "Components/Table", + component: Table, + // Table is a compound component; document its parts alongside the root so the + // args table gets a tab per part and the manifest records the relationship. + subcomponents: { TableHeader, TableBody, TableRow, TableHead, TableCell }, + tags: ["ai-generated"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const PEOPLE = [ + { name: "Ada Lovelace", role: "Admin", email: "ada@plane.so" }, + { name: "Grace Hopper", role: "Member", email: "grace@plane.so" }, + { name: "Linus Torvalds", role: "Guest", email: "linus@plane.so" }, +]; + +/** A small populated table: a header row plus three data rows. */ +export const Default: Story = { + render: () => ( +
`). Pass `variant="sortable"` (with `sort`/`onSort`) to turn + * the label into an interactive sort control: it renders a lucide chevron + * (aria-hidden) and reflects the order via `aria-sort` for assistive tech. + */ +export function TableHead({ + variant = "default", + sort = "none", + onSort, + children, + ...props +}: TableHeadProps) { + const isSortable = variant === "sortable"; + const SortGlyph = sortIcon[sort]; + return ( + + {isSortable ? ( + + ) : ( + {children} + )} + `). 38px tall with `px-4 py-2` and `text-13` body text. */ +export function TableCell({ children, ...props }: TableCellProps) { + return ( + + {children} +
+ + + Name + Role + Email + + + + {PEOPLE.map((person) => ( + + {person.name} + {person.role} + {person.email} + + ))} + +
+ ), + play: async ({ canvas }) => { + // Three column headers + three data rows (plus the header row). + await expect(canvas.getAllByRole("columnheader")).toHaveLength(3); + await expect(canvas.getAllByRole("row")).toHaveLength(4); + await expect(canvas.getAllByRole("cell")).toHaveLength(9); + }, +}; + +/** + * A sortable header: `variant="sortable"` renders the label as a button with a + * sort chevron and reflects the order through `aria-sort`. Clicking cycles + * none → asc → desc. + */ +export const Sortable: Story = { + render: () => { + const [sort, setSort] = React.useState("none"); + const cycle = () => setSort((s) => (s === "none" ? "asc" : s === "asc" ? "desc" : "none")); + return ( + + + + + Name + + Role + Email + + + + {PEOPLE.map((person) => ( + + {person.name} + {person.role} + {person.email} + + ))} + +
+ ); + }, + play: async ({ canvas }) => { + // The sortable header starts at aria-sort="none" and exposes a button. + const header = canvas.getAllByRole("columnheader")[0]; + await expect(header).toHaveAttribute("aria-sort", "none"); + const button = canvas.getByRole("button", { name: "Name" }); + // Clicking the control toggles the column's sort order. + await userEvent.click(button); + await expect(header).toHaveAttribute("aria-sort", "ascending"); + await userEvent.click(button); + await expect(header).toHaveAttribute("aria-sort", "descending"); + }, +}; From 65f66d8212cb056c720bc09be842fe65be9e2659 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 9 Jun 2026 01:08:08 +0700 Subject: [PATCH 02/16] Table: use div wrappers in th/td; gate sort button on onSort TableHead/TableCell wrapped children in a , which restricts them to phrasing content and produces invalid HTML when block-level children are passed into /. Switch to
wrappers. Also, a sortable header without an onSort handler rendered a focusable ) : ( - {children} +
{children}
)} ); @@ -139,7 +143,7 @@ export type TableCellProps = Omit, "className" | "sty export function TableCell({ children, ...props }: TableCellProps) { return ( - {children} +
{children}
); } From d0e4a31c57cea5be92485370bb2779bb90dc8c0f Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 9 Jun 2026 16:20:58 +0700 Subject: [PATCH 03/16] Table: remove cva defaultVariants; make TableHead variant required The TableHead variant axis (default/sortable) no longer defaults to "default" via cva. variant is now a required prop, so every header cell declares its visual treatment explicitly. sort/onSort remain optional (additive, only meaningful for sortable headers). --- packages/propel/src/components/table/index.tsx | 13 ++----------- .../propel/src/components/table/table.stories.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/propel/src/components/table/index.tsx b/packages/propel/src/components/table/index.tsx index 3a426d44..64fdff7a 100644 --- a/packages/propel/src/components/table/index.tsx +++ b/packages/propel/src/components/table/index.tsx @@ -55,9 +55,6 @@ const tableHeadVariants = cva("h-[38px] px-4 py-2 text-start align-middle text-1 sortable: "bg-surface-1 text-tertiary", }, }, - defaultVariants: { - variant: "default", - }, }); /** Sort direction of a sortable `TableHead`. `none` shows the neutral affordance. */ @@ -81,7 +78,7 @@ export type TableHeadProps = Omit, "className" | "sty * Visual treatment. `sortable` switches to the surface background and renders * a clickable button with a sort chevron; set it whenever you pass `sort`. */ - variant?: NonNullable["variant"]>; + variant: NonNullable["variant"]>; /** * Current sort state for a sortable header. Drives both the chevron icon * (`asc`→up, `desc`→down, `none`→up/down) and the cell's `aria-sort`. @@ -96,13 +93,7 @@ export type TableHeadProps = Omit, "className" | "sty * the label into an interactive sort control: it renders a lucide chevron * (aria-hidden) and reflects the order via `aria-sort` for assistive tech. */ -export function TableHead({ - variant = "default", - sort = "none", - onSort, - children, - ...props -}: TableHeadProps) { +export function TableHead({ variant, sort = "none", onSort, children, ...props }: TableHeadProps) { const isSortable = variant === "sortable"; // Only render the interactive sort control when there's a handler to drive it; // a sortable-styled header without `onSort` falls back to a plain label so we diff --git a/packages/propel/src/components/table/table.stories.tsx b/packages/propel/src/components/table/table.stories.tsx index 0375152a..81a7008c 100644 --- a/packages/propel/src/components/table/table.stories.tsx +++ b/packages/propel/src/components/table/table.stories.tsx @@ -35,9 +35,9 @@ export const Default: Story = { - Name - Role - Email + Name + Role + Email @@ -75,8 +75,8 @@ export const Sortable: Story = { Name - Role - Email + Role + Email From 79f4db998c4ba36bc6ed64e7ce11522342df7422 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 9 Jun 2026 16:46:54 +0700 Subject: [PATCH 04/16] Table stories: add Figma design link to meta parameters --- packages/propel/src/components/table/table.stories.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/propel/src/components/table/table.stories.tsx b/packages/propel/src/components/table/table.stories.tsx index 81a7008c..80a347ff 100644 --- a/packages/propel/src/components/table/table.stories.tsx +++ b/packages/propel/src/components/table/table.stories.tsx @@ -18,6 +18,12 @@ const meta = { // args table gets a tab per part and the manifest records the relationship. subcomponents: { TableHeader, TableBody, TableRow, TableHead, TableCell }, tags: ["ai-generated"], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/ioN74zM1xMGbcPemsxs4J1/Global-components?node-id=4017-653", + }, + }, } satisfies Meta; export default meta; From 1158218b71263d3af6987e8f788a96cbc438f251 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 10 Jun 2026 12:02:53 +0700 Subject: [PATCH 05/16] Table: add Spreadsheet variant + editable cells via Dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address design review on PR #15 (Figma 4017-653): - Two variants on the root `Table` via a required `variant` prop, read by the cells through context: `table` (row dividers only) and `spreadsheet` (full grid — every cell bordered). Both share the Figma cell metrics (38px, px-3 py-2, header on layer-1, body on surface-1) and a rounded outer border. - `TableEditableCell`: a click-to-edit cell whose value + trailing chevron is the trigger for a propel Dropdown, picking a new value that updates the row in place (Figma "Account type" editable cell). Owns the Dropdown root + trigger; the consumer passes the DropdownContent. - Stories: Default (Table), Spreadsheet, Sortable, EditableCells (click-cell → dropdown → value updates), all with the 4017-653 design link. Pagination is intentionally left as a separate component to compose, per review. --- .../propel/src/components/table/index.tsx | 182 +++++++++++++++--- .../src/components/table/table.stories.tsx | 175 +++++++++++++++-- 2 files changed, 311 insertions(+), 46 deletions(-) diff --git a/packages/propel/src/components/table/index.tsx b/packages/propel/src/components/table/index.tsx index 64fdff7a..95ba8a18 100644 --- a/packages/propel/src/components/table/index.tsx +++ b/packages/propel/src/components/table/index.tsx @@ -1,22 +1,45 @@ -import { cva, type VariantProps } from "class-variance-authority"; +import { cva, cx, type VariantProps } from "class-variance-authority"; import { ChevronDown, ChevronsUpDown, ChevronUp } from "lucide-react"; import * as React from "react"; +import { Dropdown, DropdownTrigger } from "../dropdown/index"; -// The whole table renders on `background/surface-1` with a subtle 1px divider -// between rows (Figma "Table" — header 38px, cells 38px, `px-4 py-2`). +// Figma "Table" node 4017-653 ships two layouts that share the same cell metrics +// (38px tall, px-3 py-2, header on `background/layer/1`, body on `surface-1`) and +// differ only in their dividers: +// • `table` — row dividers only (header underline + a hairline under each +// body row); no vertical rules. +// • `spreadsheet` — a full grid: every header and body cell draws a 0.5px +// `border/subtle` on all sides. +// The variant is owned by the root and read by the cells through context, so a +// consumer only sets it once on `
`. -export type TableProps = Omit, "className" | "style">; +export type TableVariant = "table" | "spreadsheet"; + +const TableVariantContext = React.createContext("table"); + +export type TableProps = Omit, "className" | "style"> & { + /** + * Layout (required). `table` draws row dividers only; `spreadsheet` draws a full + * grid (every cell bordered on all sides). Both share the same cell metrics. + */ + variant: TableVariant; +}; /** * Root `
`. Compose with `TableHeader`, `TableBody`, `TableRow`, - * `TableHead`, and `TableCell`. Renders on `surface-1` with `text-13` body text. + * `TableHead`, `TableCell`, and `TableEditableCell`. Renders on `surface-1` with a + * rounded hairline outer border (Figma `radius/lg`). Pass `variant="table"` for row + * dividers or `variant="spreadsheet"` for a full grid; the cells read it via context. */ -export function Table(props: TableProps) { +export function Table({ variant, ...props }: TableProps) { return ( -
+ + {/* `overflow-clip` lets the rounded outer border clip the corner cells. */} +
+ ); } @@ -24,7 +47,7 @@ export type TableHeaderProps = Omit, "className" | /** Header section (``). Holds a single `TableRow` of `TableHead` cells. */ export function TableHeader(props: TableHeaderProps) { - return ; + return ; } export type TableBodyProps = Omit, "className" | "style">; @@ -37,22 +60,36 @@ export function TableBody(props: TableBodyProps) { export type TableRowProps = Omit, "className" | "style">; /** - * A table row (``). Body rows draw a subtle bottom divider and tint on hover; - * the divider after the last row is hidden so the table closes cleanly. + * A table row (``). Body rows tint on hover. Dividers are drawn per-cell (so the + * `spreadsheet` grid works), so the row itself carries only the hover treatment. */ export function TableRow(props: TableRowProps) { - return ; + // `group/body-row` so a `table`-variant cell can drop its bottom divider on the + // last row (`group-last/body-row:border-b-0`) and close the rounded table cleanly. + return ; } -// Header cells follow the Figma "Table header" component: 38px tall, `px-4 py-2`, -// `text-12` semibold. A plain header sits on `background/layer/1` with -// `text/secondary`; a sortable header switches to `surface-1` + `text/tertiary` -// and exposes a sort affordance. -const tableHeadVariants = cva("h-[38px] px-4 py-2 text-start align-middle text-12 font-semibold", { +// Per-variant cell borders. In `table`, only a bottom hairline divides rows (header +// uses the 1px `border/sm`, body cells the 0.5px `border/xs`); in `spreadsheet`, +// every cell is fully bordered (0.5px `border/xs` on all sides) to form the grid. +const headBorder: Record = { + table: "border-b border-subtle", + spreadsheet: "border-[0.5px] border-subtle", +}; +const cellBorder: Record = { + // Last body row drops its bottom divider so the rounded table closes cleanly. + table: "border-b-[0.5px] border-subtle group-last/body-row:border-b-0", + spreadsheet: "border-[0.5px] border-subtle", +}; + +// Header cells follow the Figma "Table header" component: 38px tall, `px-3 py-2`, +// `text-12` semibold on `background/layer/1`. A plain header uses `text/secondary`; +// a sortable header switches to `text/tertiary` and exposes a sort affordance. +const tableHeadVariants = cva("h-[38px] px-3 py-2 text-start align-middle text-12 font-semibold", { variants: { variant: { default: "bg-layer-1 text-secondary", - sortable: "bg-surface-1 text-tertiary", + sortable: "bg-layer-1 text-tertiary", }, }, }); @@ -75,8 +112,8 @@ const ariaSort: Record = { export type TableHeadProps = Omit, "className" | "style" | "aria-sort"> & VariantProps & { /** - * Visual treatment. `sortable` switches to the surface background and renders - * a clickable button with a sort chevron; set it whenever you pass `sort`. + * Visual treatment. `sortable` renders a clickable button with a sort chevron; + * set it whenever you pass `sort`. */ variant: NonNullable["variant"]>; /** @@ -94,6 +131,7 @@ export type TableHeadProps = Omit, "className" | "sty * (aria-hidden) and reflects the order via `aria-sort` for assistive tech. */ export function TableHead({ variant, sort = "none", onSort, children, ...props }: TableHeadProps) { + const tableVariant = React.useContext(TableVariantContext); const isSortable = variant === "sortable"; // Only render the interactive sort control when there's a handler to drive it; // a sortable-styled header without `onSort` falls back to a plain label so we @@ -104,7 +142,7 @@ export function TableHead({ variant, sort = "none", onSort, children, ...props } ); } + +export type TableEditableCellProps = Omit< + React.ComponentProps<"td">, + "className" | "style" | "children" +> & { + /** + * The current value shown in the cell (e.g. `"Admin"`). It sits before the + * trailing chevron and labels the trigger button. + */ + value: React.ReactNode; + /** + * The dropdown menu shown when the cell is clicked. Pass a propel `Dropdown` + * composition — typically a `DropdownContent` with `DropdownItem`s. The cell + * owns the `Dropdown` root + the trigger; you only supply the menu surface. + */ + children: React.ReactNode; + /** + * Whether the menu is open (controlled). Pair with `onOpenChange`; omit for an + * uncontrolled cell that manages its own open state. + */ + open?: boolean; + /** Default open state for an uncontrolled cell. @default false */ + defaultOpen?: boolean; + /** Called when the menu requests to open or close. */ + onOpenChange?: (open: boolean) => void; + /** Disables the trigger so the cell can't be edited. */ + disabled?: boolean; + /** Accessible name for the trigger when the value alone isn't descriptive. */ + "aria-label"?: string; +}; + +/** + * An editable data cell (` + ); +} diff --git a/packages/propel/src/components/table/table.stories.tsx b/packages/propel/src/components/table/table.stories.tsx index 80a347ff..246e9841 100644 --- a/packages/propel/src/components/table/table.stories.tsx +++ b/packages/propel/src/components/table/table.stories.tsx @@ -1,10 +1,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import * as React from "react"; -import { expect, userEvent } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; +import { DropdownContent, DropdownItem } from "../dropdown/index"; import { Table, TableBody, TableCell, + TableEditableCell, TableHead, TableHeader, TableRow, @@ -16,7 +18,7 @@ const meta = { component: Table, // Table is a compound component; document its parts alongside the root so the // args table gets a tab per part and the manifest records the relationship. - subcomponents: { TableHeader, TableBody, TableRow, TableHead, TableCell }, + subcomponents: { TableHeader, TableBody, TableRow, TableHead, TableCell, TableEditableCell }, tags: ["ai-generated"], parameters: { design: { @@ -29,39 +31,112 @@ const meta = { export default meta; type Story = StoryObj; -const PEOPLE = [ - { name: "Ada Lovelace", role: "Admin", email: "ada@plane.so" }, - { name: "Grace Hopper", role: "Member", email: "grace@plane.so" }, - { name: "Linus Torvalds", role: "Guest", email: "linus@plane.so" }, +type Person = { name: string; display: string; email: string; role: string; billing: string }; + +const PEOPLE: Person[] = [ + { + name: "Chargers", + display: "astra", + email: "astra.terra@example.com", + role: "Admin", + billing: "Active", + }, + { + name: "Alpha", + display: "nova", + email: "nova.star@example.com", + role: "Member", + billing: "Inactive", + }, + { + name: "Beta", + display: "lyra", + email: "lyra.constellation@example.com", + role: "Member", + billing: "Active", + }, + { + name: "Gamma", + display: "pulsar", + email: "vega.pulsar@example.com", + role: "Guest", + billing: "Inactive", + }, ]; -/** A small populated table: a header row plus three data rows. */ +const COLUMNS = ["Name", "Display name", "Email", "Account type", "Billing status"]; + +/** + * The standard **Table** (`variant="table"`): a rounded outer border with row + * dividers only (no vertical rules). Header on `layer-1`, body on `surface-1`. + */ export const Default: Story = { - render: () => ( -
{hasSortControl ? ( @@ -128,13 +166,107 @@ function ChevronGlyphSlot({ Glyph }: { Glyph: typeof ChevronsUpDown }) { return ; } +// Shared `` chrome for both the plain and editable cells: 38px tall, px-3 py-2, +// `align-middle`, plus the per-variant border read from context. +function useTableCellClass() { + const tableVariant = React.useContext(TableVariantContext); + return cx("h-[38px] px-3 py-2 align-middle", cellBorder[tableVariant]); +} + export type TableCellProps = Omit, "className" | "style">; -/** A data cell (``). 38px tall with `px-4 py-2` and `text-13` body text. */ +/** A data cell (``). 38px tall with `px-3 py-2` and `text-13` body text. */ export function TableCell({ children, ...props }: TableCellProps) { + const className = useTableCellClass(); return ( - +
{children}
`). Renders the current `value` plus a trailing + * chevron as a single button that opens the propel `Dropdown` passed as `children` + * to pick a new value (Figma "Account type" cell). It owns the `Dropdown` root and + * trigger, so you only pass the menu surface: + * + * + * + * setRole("Admin")} /> + * setRole("Member")} /> + * + * + */ +export function TableEditableCell({ + value, + children, + open, + defaultOpen, + onOpenChange, + disabled, + "aria-label": ariaLabel, + ...props +}: TableEditableCellProps) { + const className = useTableCellClass(); + return ( + // The `` keeps no padding so the full-bleed trigger button fills the cell + // (the button re-applies the px-3/py-2 inset). This keeps the click target the + // whole cell, matching the Figma editable cell. + + + + } + > + {value} + {/* 14px chevron in a 20px icon slot, mirroring the Figma trigger. */} + + + + + {children} + +
+ args: { variant: "table" }, + render: (args) => ( +
- Name - Role - Email + {COLUMNS.map((c) => ( + + {c} + + ))} {PEOPLE.map((person) => ( {person.name} + {person.display} + {person.email} {person.role} + {person.billing} + + ))} + +
+ ), + play: async ({ canvas }) => { + await expect(canvas.getAllByRole("columnheader")).toHaveLength(5); + // 1 header row + 4 data rows. + await expect(canvas.getAllByRole("row")).toHaveLength(5); + await expect(canvas.getAllByRole("cell")).toHaveLength(20); + }, +}; + +/** + * The denser **Spreadsheet** (`variant="spreadsheet"`): the same metrics, but every + * cell is fully bordered to form a grid (Figma "Spreadsheet"). + */ +export const Spreadsheet: Story = { + args: { variant: "spreadsheet" }, + render: (args) => ( + + + + {COLUMNS.map((c) => ( + + {c} + + ))} + + + + {PEOPLE.map((person) => ( + + {person.name} + {person.display} {person.email} + {person.role} + {person.billing} ))}
), play: async ({ canvas }) => { - // Three column headers + three data rows (plus the header row). - await expect(canvas.getAllByRole("columnheader")).toHaveLength(3); - await expect(canvas.getAllByRole("row")).toHaveLength(4); - await expect(canvas.getAllByRole("cell")).toHaveLength(9); + await expect(canvas.getAllByRole("columnheader")).toHaveLength(5); + await expect(canvas.getAllByRole("row")).toHaveLength(5); }, }; @@ -71,17 +146,18 @@ export const Default: Story = { * none → asc → desc. */ export const Sortable: Story = { - render: () => { + args: { variant: "table" }, + render: (args) => { const [sort, setSort] = React.useState("none"); const cycle = () => setSort((s) => (s === "none" ? "asc" : s === "asc" ? "desc" : "none")); return ( - +
Name - Role + Account type Email @@ -98,14 +174,71 @@ export const Sortable: Story = { ); }, play: async ({ canvas }) => { - // The sortable header starts at aria-sort="none" and exposes a button. const header = canvas.getAllByRole("columnheader")[0]; await expect(header).toHaveAttribute("aria-sort", "none"); const button = canvas.getByRole("button", { name: "Name" }); - // Clicking the control toggles the column's sort order. await userEvent.click(button); await expect(header).toHaveAttribute("aria-sort", "ascending"); await userEvent.click(button); await expect(header).toHaveAttribute("aria-sort", "descending"); }, }; + +const ROLES = ["Admin", "Member", "Guest"]; + +/** + * **Editable cells.** Each "Account type" cell is a `TableEditableCell`: clicking it + * opens a propel `Dropdown` to pick a new value, which updates the row in place + * (Figma "Account type" editable cell). Works in both table variants. + */ +export const EditableCells: Story = { + args: { variant: "table" }, + render: (args) => { + const [people, setPeople] = React.useState(PEOPLE); + const setRole = (email: string, role: string) => + setPeople((rows) => rows.map((r) => (r.email === email ? { ...r, role } : r))); + return ( +
+ + + Name + Email + Account type + + + + {people.map((person) => ( + + {person.name} + {person.email} + + + {ROLES.map((role) => ( + setRole(person.email, role)} + /> + ))} + + + + ))} + +
+ ); + }, + play: async ({ canvas }) => { + // The first editable cell opens a menu; picking a role updates the cell value. + const trigger = canvas.getByRole("button", { name: "Account type for Chargers" }); + await expect(trigger).toHaveTextContent("Admin"); + await userEvent.click(trigger); + // The menu renders in a portal — query the document body. + const menu = within(document.body); + const member = await menu.findByRole("menuitem", { name: "Member" }); + await userEvent.click(member); + await expect(trigger).toHaveTextContent("Member"); + }, +}; From fdb230dd70a3ebb1112e195b73c9d4785c8cad80 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 10 Jun 2026 12:18:14 +0700 Subject: [PATCH 06/16] Table: add keyboard play tests for sortable header and editable cell SortableKeyboard: Tab to the sortable header button, then Enter/Space cycle the sort (none -> ascending -> descending -> none) with aria-sort and onSort following along; asserts the th scope=col semantics. EditableCellKeyboard: Tab+Enter opens the portaled Dropdown, Arrow Down + Enter selects a value and the cell updates in place, and Escape closes the menu and returns focus to the cell trigger. The portaled menu is queried from the document body by unique item text with waitFor for open/close. Both tagged [!dev,!autodocs,!manifest] so they run under test only. --- .../src/components/table/table.stories.tsx | 133 +++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/packages/propel/src/components/table/table.stories.tsx b/packages/propel/src/components/table/table.stories.tsx index 246e9841..edf507e6 100644 --- a/packages/propel/src/components/table/table.stories.tsx +++ b/packages/propel/src/components/table/table.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import * as React from "react"; -import { expect, userEvent, within } from "storybook/test"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import { DropdownContent, DropdownItem } from "../dropdown/index"; import { Table, @@ -242,3 +242,134 @@ export const EditableCells: Story = { await expect(trigger).toHaveTextContent("Member"); }, }; + +/** + * **Keyboard: sortable header.** Tab to the sortable column-header button and toggle + * the sort with the keyboard: each Enter/Space advances the cycle + * (none → ascending → descending → none) and the ``'s `aria-sort` follows along, + * while `onSort` fires on every activation. Tagged so it stays out of the sidebar, + * docs, and AI manifest while still running under the default `test` tag. + */ +export const SortableKeyboard: Story = { + tags: ["!dev", "!autodocs", "!manifest"], + args: { variant: "table" }, + render: function SortableKeyboardStory(args) { + const [sort, setSort] = React.useState("none"); + const cycle = () => setSort((s) => (s === "none" ? "asc" : s === "asc" ? "desc" : "none")); + return ( + + + + + Name + + Email + + + + {PEOPLE.map((person) => ( + + {person.name} + {person.email} + + ))} + +
+ ); + }, + play: async ({ canvas }) => { + const header = canvas.getAllByRole("columnheader")[0]; + if (!header) throw new Error("expected a sortable column header"); + // Table semantics: the header is a proper `` that starts unsorted. + await expect(header.tagName).toBe("TH"); + await expect(header).toHaveAttribute("scope", "col"); + await expect(header).toHaveAttribute("aria-sort", "none"); + + const button = canvas.getByRole("button", { name: "Name" }); + // Tab moves focus onto the sort-control button (it's the first focusable element). + await userEvent.tab(); + await expect(button).toHaveFocus(); + + // Enter advances the cycle: none → ascending. + await userEvent.keyboard("{Enter}"); + await expect(header).toHaveAttribute("aria-sort", "ascending"); + + // Space advances again: ascending → descending. + await userEvent.keyboard(" "); + await expect(header).toHaveAttribute("aria-sort", "descending"); + + // Enter once more wraps back to none. + await userEvent.keyboard("{Enter}"); + await expect(header).toHaveAttribute("aria-sort", "none"); + }, +}; + +/** + * **Keyboard: editable cell.** Tab/Enter on the editable cell's trigger opens the + * portaled `Dropdown`; Arrow Down moves the highlight onto the first item and Enter + * selects it, updating the cell value in place; Escape closes the menu and returns + * focus to the cell trigger. The menu is portaled, so it's queried from the document + * body by its unique item text. Tagged so it stays out of the sidebar, docs, and AI + * manifest while still running under the default `test` tag. + */ +export const EditableCellKeyboard: Story = { + tags: ["!dev", "!autodocs", "!manifest"], + args: { variant: "table" }, + render: function EditableCellKeyboardStory(args) { + const [role, setRole] = React.useState("Admin"); + return ( + + + + Name + Account type + + + + + Chargers + + + {ROLES.map((r) => ( + setRole(r)} + /> + ))} + + + + +
+ ); + }, + play: async ({ canvas }) => { + const menu = within(document.body); + const trigger = canvas.getByRole("button", { name: "Account type for Chargers" }); + await expect(trigger).toHaveTextContent("Admin"); + + // Tab focuses the cell trigger; Enter opens the portaled menu. + await userEvent.tab(); + await expect(trigger).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + // The menu is portaled — find it by its unique item text, not a bare role. + await waitFor(() => expect(menu.getByRole("menuitem", { name: "Member" })).toBeVisible()); + + // Opening with Enter already highlights the first item ("Admin"); one Arrow Down + // moves the highlight onto "Member", then Enter selects it and the cell updates. + await userEvent.keyboard("{ArrowDown}{Enter}"); + await expect(trigger).toHaveTextContent("Member"); + // Selecting closed the menu. + await waitFor(() => expect(menu.queryByRole("menuitem", { name: "Member" })).toBeNull()); + + // Reopen with the keyboard, then Escape closes it and restores focus to the trigger. + await userEvent.keyboard("{Enter}"); + await waitFor(() => expect(menu.getByRole("menuitem", { name: "Guest" })).toBeVisible()); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(menu.queryByRole("menuitem", { name: "Guest" })).toBeNull()); + await expect(trigger).toHaveFocus(); + }, +}; From 7c3ae24d94717c3a2f249518cb4374554bb5efac Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 10 Jun 2026 17:28:08 +0700 Subject: [PATCH 07/16] Table: add a WithPagination story composing the Pagination component Designer follow-up on #15: pagination can be used with the table. The Table and Pagination stay separate components, so the story keeps the full dataset, renders only the current page of rows, and lets the Pagination below drive page and page size (changing the size resets to page 1 and updates the range label). Verified in Storybook (table variants, editable cell, and pagination) plus a play test that advances a page and asserts the rows swap. --- .../src/components/table/table.stories.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/propel/src/components/table/table.stories.tsx b/packages/propel/src/components/table/table.stories.tsx index edf507e6..51757525 100644 --- a/packages/propel/src/components/table/table.stories.tsx +++ b/packages/propel/src/components/table/table.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import * as React from "react"; import { expect, userEvent, waitFor, within } from "storybook/test"; import { DropdownContent, DropdownItem } from "../dropdown/index"; +import { Pagination } from "../pagination/index"; import { Table, TableBody, @@ -243,6 +244,81 @@ export const EditableCells: Story = { }, }; +// A larger directory so the table has enough rows to page through. +const DIRECTORY: Person[] = Array.from({ length: 23 }, (_, i) => { + const base = PEOPLE[i % PEOPLE.length]; + return { + ...base, + name: `${base.name} ${i + 1}`, + email: `user${i + 1}@example.com`, + }; +}); + +/** + * **With Pagination.** The Table and `Pagination` are separate components: the table + * renders only the current page of rows, and the pagination below drives `page` and + * `pageSize`. Changing the page size resets to the first page and the range label + * updates. This is the designer follow-up on composing the two. + */ +export const WithPagination: Story = { + args: { variant: "table" }, + render: (args) => { + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(5); + const pageCount = Math.ceil(DIRECTORY.length / pageSize); + const start = (page - 1) * pageSize; + const rows = DIRECTORY.slice(start, start + pageSize); + return ( +
+ + + + Name + Email + Account type + Billing status + + + + {rows.map((person) => ( + + {person.name} + {person.email} + {person.role} + {person.billing} + + ))} + +
+ { + setPageSize(next); + setPage(1); + }, + }} + range={{ + current: `${start + 1}-${Math.min(start + pageSize, DIRECTORY.length)}`, + total: DIRECTORY.length, + }} + /> +
+ ); + }, + play: async ({ canvas }) => { + // Page 1 shows the first slice; advancing the pagination swaps the table's rows. + await expect(canvas.getByText("user1@example.com")).toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Go to next page" })); + await waitFor(() => expect(canvas.queryByText("user1@example.com")).not.toBeInTheDocument()); + await expect(canvas.getByText("user6@example.com")).toBeInTheDocument(); + }, +}; + /** * **Keyboard: sortable header.** Tab to the sortable column-header button and toggle * the sort with the keyboard: each Enter/Space advances the cycle From f54e55b6cf3d3575cccd43a22caed0dfca230a38 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 10 Jun 2026 17:54:51 +0700 Subject: [PATCH 08/16] Storybook: drop the ai-generated tag from the table story --- packages/propel/src/components/table/table.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/propel/src/components/table/table.stories.tsx b/packages/propel/src/components/table/table.stories.tsx index 51757525..4df72b49 100644 --- a/packages/propel/src/components/table/table.stories.tsx +++ b/packages/propel/src/components/table/table.stories.tsx @@ -20,7 +20,6 @@ const meta = { // Table is a compound component; document its parts alongside the root so the // args table gets a tab per part and the manifest records the relationship. subcomponents: { TableHeader, TableBody, TableRow, TableHead, TableCell, TableEditableCell }, - tags: ["ai-generated"], parameters: { design: { type: "figma", From 53d988804a4008829f1d9fb8922a468fce18caea Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 10 Jun 2026 21:55:50 +0700 Subject: [PATCH 09/16] Table: fence the TableEditableCell TSDoc usage example as a code block Same Markdown-in-autodocs issue as Popover/Tabs/Dropdown: the indented JSX example let the wrapper tag leak out of the code box. Wrap it in a ```tsx fence. --- packages/propel/src/components/table/index.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/propel/src/components/table/index.tsx b/packages/propel/src/components/table/index.tsx index 95ba8a18..8610b336 100644 --- a/packages/propel/src/components/table/index.tsx +++ b/packages/propel/src/components/table/index.tsx @@ -221,12 +221,14 @@ export type TableEditableCellProps = Omit< * to pick a new value (Figma "Account type" cell). It owns the `Dropdown` root and * trigger, so you only pass the menu surface: * - * - * - * setRole("Admin")} /> - * setRole("Member")} /> - * - * + * ```tsx + * + * + * setRole("Admin")} /> + * setRole("Member")} /> + * + * + * ``` */ export function TableEditableCell({ value, From f03bcce012fc62c14340ded039cb3a68ede51c59 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Thu, 11 Jun 2026 20:35:17 +0700 Subject: [PATCH 10/16] Table: 44px rows, fix editable-cell height, stable pagination width, cell states Addresses design review on #15: - Rows are 44px. The editable cell was forcing rows to ~55px because its p-0 override lost to the shared cell padding under cx; padding now lives on each cell type, so the editable cell is truly full-bleed and its 44px button sets the height. - Editable cells get an explicit open/selected state (data-[popup-open]) next to the existing hover and focus states. - The WithPagination demo gets a fixed width so the table no longer resizes between pages (it is w-full and was shrink-to-fitting each page's content under centered layout). The table already has the border-subtle outer border. --- .../propel/src/components/table/index.tsx | 19 ++++++++++++------- .../src/components/table/table.stories.tsx | 5 ++++- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/propel/src/components/table/index.tsx b/packages/propel/src/components/table/index.tsx index 8610b336..612a9df5 100644 --- a/packages/propel/src/components/table/index.tsx +++ b/packages/propel/src/components/table/index.tsx @@ -166,20 +166,23 @@ function ChevronGlyphSlot({ Glyph }: { Glyph: typeof ChevronsUpDown }) { return ; } -// Shared `` chrome for both the plain and editable cells: 38px tall, px-3 py-2, -// `align-middle`, plus the per-variant border read from context. +// Shared `` chrome for both the plain and editable cells: 44px tall, `align-middle`, +// plus the per-variant border read from context. Padding is intentionally NOT included +// here: the plain cell adds `px-3 py-2`, while the editable cell stays `p-0` so its +// full-bleed button supplies the inset. (Baking padding in and overriding it with `p-0` +// is order-fragile under `cx`, which is what made editable rows render too tall.) function useTableCellClass() { const tableVariant = React.useContext(TableVariantContext); - return cx("h-[38px] px-3 py-2 align-middle", cellBorder[tableVariant]); + return cx("h-11 align-middle", cellBorder[tableVariant]); } export type TableCellProps = Omit, "className" | "style">; -/** A data cell (``). 38px tall with `px-3 py-2` and `text-13` body text. */ +/** A data cell (``). 44px tall with `px-3 py-2` and `text-13` body text. */ export function TableCell({ children, ...props }: TableCellProps) { const className = useTableCellClass(); return ( - +
{children}
); @@ -254,8 +257,10 @@ export function TableEditableCell({