From d5867f406f74e2d54fa38f2d8ab4b0a4bb9070c1 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Fri, 26 Jun 2026 19:22:05 +0700 Subject: [PATCH 01/12] table: rename cva axis surface->mode; require pinned/padding (rule 10, no defaults) The public mode prop (TableCell/TableHead) was remapped onto a misnamed cva axis 'surface', so the parts couldn't use & VariantProps. Renamed the axis to mode (matching the public prop, the TableMode type, and the vocabulary: 'surface' is a host background it adapts to, which table/spreadsheet are not). Dropped the function-level pinned/padding defaults so every axis is required (first pass, no defaults); Props intersect the derived TableCell/TableHeadVariantProps (private). components cells read mode from context and forward the required pinned/padding; call sites pass them explicitly. --- .../components/table/table-action-cell.tsx | 7 +- .../src/components/table/table-cell.tsx | 2 +- .../components/table/table-editable-cell.tsx | 7 +- .../src/components/table/table.stories.tsx | 151 ++++++++++++------ packages/propel/src/ui/table/table-cell.tsx | 21 +-- packages/propel/src/ui/table/table-head.tsx | 12 +- .../propel/src/ui/table/table.stories.tsx | 42 ++--- packages/propel/src/ui/table/variants.ts | 32 ++-- 8 files changed, 170 insertions(+), 104 deletions(-) diff --git a/packages/propel/src/components/table/table-action-cell.tsx b/packages/propel/src/components/table/table-action-cell.tsx index cf21d322..9d958e5a 100644 --- a/packages/propel/src/components/table/table-action-cell.tsx +++ b/packages/propel/src/components/table/table-action-cell.tsx @@ -10,7 +10,10 @@ import { } from "../../ui/table/index"; import { useTableMode } from "./table-context"; -export type TableActionCellProps = Omit & { +export type TableActionCellProps = Omit< + TableCellProps, + "padding" | "pinned" | "children" | "mode" +> & { /** The menu of row actions. */ children: React.ReactNode; /** Accessible name for the trigger (e.g. "Row options"). Required (icon-only). */ @@ -40,7 +43,7 @@ export function TableActionCell({ }: TableActionCellProps) { const mode = useTableMode(); return ( - + & { +export type TableCellProps = Omit & { /** Leading content beside the cell text — an icon or an `Avatar`. */ inlineStartNode?: React.ReactNode; /** Trailing content beside the cell text — an icon or an `Avatar`. */ diff --git a/packages/propel/src/components/table/table-editable-cell.tsx b/packages/propel/src/components/table/table-editable-cell.tsx index e37f5e70..b3e3be21 100644 --- a/packages/propel/src/components/table/table-editable-cell.tsx +++ b/packages/propel/src/components/table/table-editable-cell.tsx @@ -11,7 +11,10 @@ import { } from "../../ui/table/index"; import { useTableMode } from "./table-context"; -export type TableEditableCellProps = Omit & { +export type TableEditableCellProps = Omit< + TableCellProps, + "padding" | "pinned" | "children" | "mode" +> & { /** The current value shown in the cell. */ value: React.ReactNode; /** The menu shown when the cell is clicked. */ @@ -44,7 +47,7 @@ export function TableEditableCell({ }: TableEditableCellProps) { const mode = useTableMode(); return ( - + {COLUMNS.map((c) => ( - {c} + + {c} + ))} @@ -97,16 +99,26 @@ export const Default: Story = { {PEOPLE.map((person) => ( } > {person.name} - {person.display} - {person.email} - {person.role} - {person.billing} + + {person.display} + + + {person.email} + + + {person.role} + + + {person.billing} + ))} @@ -131,7 +143,9 @@ export const Spreadsheet: Story = { {COLUMNS.map((c) => ( - {c} + + {c} + ))} @@ -139,16 +153,26 @@ export const Spreadsheet: Story = { {PEOPLE.map((person) => ( } > {person.name} - {person.display} - {person.email} - {person.role} - {person.billing} + + {person.display} + + + {person.email} + + + {person.role} + + + {person.billing} + ))} @@ -173,25 +197,31 @@ export const Sortable: Story = { - + Name - Account type - Email + Account type + Email {PEOPLE.map((person) => ( } > {person.name} - {person.role} - {person.email} + + {person.role} + + + {person.email} + ))} @@ -228,22 +258,26 @@ export const EditableCells: Story = {
- Name - Email - Account type + Name + Email + Account type {people.map((person) => ( } > {person.name} - {person.email} + + {person.email} + - Name - Email - Account type - Billing status + Name + Email + Account type + Billing status {rows.map((person) => ( } > {person.name} - {person.email} - {person.role} - {person.billing} + + {person.email} + + + {person.role} + + + {person.billing} + ))} @@ -384,10 +426,10 @@ export const RichRows: Story = {
- Name - Email - Account type - + Name + Email + Account type + Actions @@ -396,13 +438,17 @@ export const RichRows: Story = { {people.map((person) => ( } > {person.name} - {person.email} + + {person.email} + {ROLES.map((role) => ( @@ -454,10 +500,10 @@ export const StickyHeaderAndColumns: Story = { Name - Display name - Email - Account type - Billing status + Display name + Email + Account type + Billing status @@ -465,16 +511,25 @@ export const StickyHeaderAndColumns: Story = { } > {person.name} - {person.display} - {person.email} - {person.role} - {person.billing} + + {person.display} + + + {person.email} + + + {person.role} + + + {person.billing} + ))} @@ -507,23 +562,27 @@ export const SortableKeyboard: Story = {
- + Name - Email + Email {PEOPLE.map((person) => ( } > {person.name} - {person.email} + + {person.email} + ))} @@ -573,13 +632,15 @@ export const EditableCellKeyboard: Story = {
- Name - Account type + Name + Account type - Chargers + + Chargers + {ROLES.map((r) => ( diff --git a/packages/propel/src/ui/table/table-cell.tsx b/packages/propel/src/ui/table/table-cell.tsx index 0a6a1830..3e1c3e2a 100644 --- a/packages/propel/src/ui/table/table-cell.tsx +++ b/packages/propel/src/ui/table/table-cell.tsx @@ -1,26 +1,17 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import { type TablePinned, type TableMode, tableCellVariants } from "./variants"; +import { type TableCellVariantProps, tableCellVariants } from "./variants"; -export type TableCellPadding = "cell" | "trigger"; +export type { TableCellPadding } from "./variants"; -export type TableCellProps = Omit, "className" | "style"> & { - /** The surrounding table's look, matching the `Table` root. */ - mode: TableMode; - /** Pin this cell to the inline-start/end edge when the table scrolls sideways. */ - pinned?: TablePinned; - /** - * Inner spacing. `cell` (default) pads the content; `trigger` drops the padding so a full-cell - * trigger (an editable/action cell) can fill the cell. - */ - padding?: TableCellPadding; -}; +export type TableCellProps = Omit, "className" | "style"> & + TableCellVariantProps; /** A data cell (`
`). Borders follow the `mode`. */ -export function TableCell({ mode, pinned, padding = "cell", render, ...props }: TableCellProps) { +export function TableCell({ mode, pinned, padding, render, ...props }: TableCellProps) { const defaultProps: useRender.ElementProps<"td"> = { - className: tableCellVariants({ surface: mode, pinned: pinned ?? "none", padding }), + className: tableCellVariants({ mode, pinned, padding }), }; return useRender({ defaultTagName: "td", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/table/table-head.tsx b/packages/propel/src/ui/table/table-head.tsx index a83ca51e..5bd77945 100644 --- a/packages/propel/src/ui/table/table-head.tsx +++ b/packages/propel/src/ui/table/table-head.tsx @@ -1,14 +1,10 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import { type TablePinned, type TableMode, tableHeadVariants } from "./variants"; +import { type TableHeadVariantProps, tableHeadVariants } from "./variants"; -export type TableHeadProps = Omit, "className" | "style"> & { - /** The surrounding table's look, matching the `Table` root. */ - mode: TableMode; - /** Pin this header to the inline-start/end edge when the table scrolls sideways. */ - pinned?: TablePinned; -}; +export type TableHeadProps = Omit, "className" | "style"> & + TableHeadVariantProps; /** * A header cell (``). Borders follow the `mode`. Holds a `TableHeadTitle` (or, when @@ -17,7 +13,7 @@ export type TableHeadProps = Omit, "className" | export function TableHead({ mode, pinned, render, ...props }: TableHeadProps) { const defaultProps: useRender.ElementProps<"th"> = { scope: "col", - className: tableHeadVariants({ surface: mode, pinned: pinned ?? "none" }), + className: tableHeadVariants({ mode, pinned }), }; return useRender({ defaultTagName: "th", render, props: mergeProps(defaultProps, props) }); } diff --git a/packages/propel/src/ui/table/table.stories.tsx b/packages/propel/src/ui/table/table.stories.tsx index 2e7b4238..e56f8f3e 100644 --- a/packages/propel/src/ui/table/table.stories.tsx +++ b/packages/propel/src/ui/table/table.stories.tsx @@ -62,7 +62,7 @@ export const Default: Story = { {COLUMNS.map((c) => ( - + {c} ))} @@ -71,7 +71,7 @@ export const Default: Story = { {PEOPLE.map((person) => ( - + @@ -81,17 +81,17 @@ export const Default: Story = { {person.name} - + {person.display} - + {person.email} - + {person.role} @@ -115,7 +115,7 @@ export const Spreadsheet: Story = { {COLUMNS.map((c) => ( - + {c} ))} @@ -124,22 +124,22 @@ export const Spreadsheet: Story = { {PEOPLE.map((person) => ( - + {person.name} - + {person.display} - + {person.email} - + {person.role} @@ -169,7 +169,7 @@ export const Sortable: Story = { - + Name @@ -177,7 +177,7 @@ export const Sortable: Story = { - + Email @@ -185,12 +185,12 @@ export const Sortable: Story = { {PEOPLE.map((person) => ( - + {person.name} - + {person.email} @@ -226,13 +226,13 @@ export const PinnedColumn: Story = { Name - + Display name - + Email - + Account type @@ -240,7 +240,7 @@ export const PinnedColumn: Story = { {PEOPLE.map((person) => ( - + @@ -250,17 +250,17 @@ export const PinnedColumn: Story = { {person.name} - + {person.display} - + {person.email} - + {person.role} diff --git a/packages/propel/src/ui/table/variants.ts b/packages/propel/src/ui/table/variants.ts index 244d924d..a02160b9 100644 --- a/packages/propel/src/ui/table/variants.ts +++ b/packages/propel/src/ui/table/variants.ts @@ -1,12 +1,7 @@ -/** The two table looks: `table` (row dividers only) and `spreadsheet` (full grid). */ -export type TableMode = "table" | "spreadsheet"; - -/** Which inline edge a header/cell pins to while the table scrolls sideways. */ -export type TablePinned = "start" | "end"; - -import { cva, cx } from "class-variance-authority"; +import { cva, cx, type VariantProps } from "class-variance-authority"; import { nodeSlotClass } from "../../internal/node-slot"; +import { type StrictVariantProps } from "../../internal/variant-props"; // Table is a structural data primitive. The designer locked two layout looks (Figma // "Table" vs "Spreadsheet") as the only `mode` axis, and baked everything else @@ -51,7 +46,7 @@ export const tableHeadVariants = cva( { variants: { // The surrounding table look, which decides this cell's borders. - surface: { + mode: { table: "border-b border-subtle", spreadsheet: "border-e-[0.5px] border-b-[0.5px] border-subtle last:border-e-0", }, @@ -65,11 +60,11 @@ export const tableHeadVariants = cva( }, ); -// A data cell (`
`). `surface` decides its borders; `pinned` makes it stick to an +// A data cell (``). `mode` decides its borders; `pinned` makes it stick to an // inline edge (carrying its own background so scrolled content does not show through). export const tableCellVariants = cva("h-11 align-middle", { variants: { - surface: { + mode: { table: "border-b-[0.5px] border-subtle group-last/body-row:border-b-0", spreadsheet: "border-e-[0.5px] border-b-[0.5px] border-subtle group-last/body-row:border-b-0 last:border-e-0", @@ -89,6 +84,23 @@ export const tableCellVariants = cva("h-11 align-middle", { }, }); +// Per-axis types derive from the cvas (the single source of truth). `mode`/`pinned` are shared by +// head + cell; `padding` is cell-only. The `VariantProps` bundles stay PRIVATE — a part +// imports its own for `Props` (rule 10) and never re-exports it. +type TableCellVariantConfig = VariantProps; + +/** The two table looks: `table` (row dividers only) and `spreadsheet` (full grid). */ +export type TableMode = NonNullable; + +/** Which inline edge a header/cell pins to (or `none`) while the table scrolls sideways. */ +export type TablePinned = NonNullable; + +/** A cell's inner spacing: `cell` pads the content; `trigger` drops it for a full-cell trigger. */ +export type TableCellPadding = NonNullable; + +export type TableCellVariantProps = StrictVariantProps; +export type TableHeadVariantProps = StrictVariantProps; + // The inline flex layout inside a plain cell: leading slot, growing content, trailing // slot. Padding lives on the `` (`TableCell`), not here. export const tableCellLayoutVariants = cva("flex items-center gap-2 [--node-size:1.25rem]"); From 3732c01a236f865e3811bb3894ae02c1e1c08a7c Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Fri, 26 Jun 2026 19:22:05 +0700 Subject: [PATCH 02/12] field: FieldLabel inset is required, via FieldLabelVariantProps (rule 10, no defaults) FieldLabel hand-wrote magnitude + an optional inset; Props now intersect FieldLabelVariantProps (StrictVariantProps), making inset required like every first-pass axis. Local VariantProps config renamed to FieldLabelVariantConfig; the bundle stays private. Call sites pass inset explicitly (inset={false} for stacked labels; field-label-group keeps its computed inline-row inset). --- .../autocomplete-field/autocomplete-field.tsx | 4 +++- .../autocomplete/autocomplete.stories.tsx | 4 +++- .../components/combobox-field/combobox-field.tsx | 4 +++- .../src/components/combobox/combobox.stories.tsx | 4 +++- .../src/components/field/field.stories.tsx | 16 +++++++++++----- .../src/components/input/input.stories.tsx | 8 ++++++-- .../components/text-area/text-area.stories.tsx | 8 ++++++-- .../src/ui/autocomplete/autocomplete.stories.tsx | 4 +++- .../propel/src/ui/combobox/combobox.stories.tsx | 4 +++- .../propel/src/ui/field/field-item-content.tsx | 4 +++- packages/propel/src/ui/field/field-label.tsx | 6 ++---- packages/propel/src/ui/field/field.stories.tsx | 8 +++++--- packages/propel/src/ui/field/variants.ts | 6 ++++-- .../propel/src/ui/fieldset/fieldset.stories.tsx | 4 +++- packages/propel/src/ui/form/form.stories.tsx | 8 ++++++-- .../src/ui/input-field/input-field.stories.tsx | 4 +++- packages/propel/src/ui/input/input.stories.tsx | 8 ++++++-- .../src/ui/text-area/text-area.stories.tsx | 8 ++++++-- 18 files changed, 79 insertions(+), 33 deletions(-) diff --git a/packages/propel/src/components/autocomplete-field/autocomplete-field.tsx b/packages/propel/src/components/autocomplete-field/autocomplete-field.tsx index 1f938e0c..05039dde 100644 --- a/packages/propel/src/components/autocomplete-field/autocomplete-field.tsx +++ b/packages/propel/src/components/autocomplete-field/autocomplete-field.tsx @@ -59,7 +59,9 @@ export function AutocompleteField({ return ( - {label} + + {label} + diff --git a/packages/propel/src/components/autocomplete/autocomplete.stories.tsx b/packages/propel/src/components/autocomplete/autocomplete.stories.tsx index bd20cbef..9776cff1 100644 --- a/packages/propel/src/components/autocomplete/autocomplete.stories.tsx +++ b/packages/propel/src/components/autocomplete/autocomplete.stories.tsx @@ -43,7 +43,9 @@ export const Default: Story = { render: (args) => ( - Container image + + Container image + diff --git a/packages/propel/src/components/combobox-field/combobox-field.tsx b/packages/propel/src/components/combobox-field/combobox-field.tsx index ef348413..9ade440d 100644 --- a/packages/propel/src/components/combobox-field/combobox-field.tsx +++ b/packages/propel/src/components/combobox-field/combobox-field.tsx @@ -61,7 +61,9 @@ export function ComboboxField({ return ( - {label} + + {label} + diff --git a/packages/propel/src/components/combobox/combobox.stories.tsx b/packages/propel/src/components/combobox/combobox.stories.tsx index b8a9d1e7..0bcf00f2 100644 --- a/packages/propel/src/components/combobox/combobox.stories.tsx +++ b/packages/propel/src/components/combobox/combobox.stories.tsx @@ -47,7 +47,9 @@ export const Default: Story = { render: (args) => ( - Region + + Region + diff --git a/packages/propel/src/components/field/field.stories.tsx b/packages/propel/src/components/field/field.stories.tsx index 5de04ede..8b4800b6 100644 --- a/packages/propel/src/components/field/field.stories.tsx +++ b/packages/propel/src/components/field/field.stories.tsx @@ -20,7 +20,7 @@ type Story = StoryObj; export const Default: Story = { render: () => ( - + Display name @@ -33,7 +33,9 @@ export const Default: Story = { export const Invalid: Story = { render: () => ( - Workspace slug + + Workspace slug + Choose a different workspace slug. @@ -46,7 +48,7 @@ export const LabelAndDescription: Story = { tags: ["!dev", "!autodocs", "!manifest"], render: () => ( - + Custom field @@ -65,7 +67,9 @@ export const ErrorAssociation: Story = { tags: ["!dev", "!autodocs", "!manifest"], render: () => ( - Email + + Email + Enter a valid email address. @@ -83,7 +87,9 @@ export const TypingUpdatesValue: Story = { tags: ["!dev", "!autodocs", "!manifest"], render: () => ( - Nickname + + Nickname + ), diff --git a/packages/propel/src/components/input/input.stories.tsx b/packages/propel/src/components/input/input.stories.tsx index 85af300e..bbaf9e5f 100644 --- a/packages/propel/src/components/input/input.stories.tsx +++ b/packages/propel/src/components/input/input.stories.tsx @@ -96,7 +96,9 @@ export const FieldComposition: Story = { args: { magnitude: "md" }, render: (args) => ( - Display name + + Display name + @@ -115,7 +117,9 @@ export const FieldErrorAssociation: Story = { args: { magnitude: "md" }, render: (args) => ( - Email + + Email + diff --git a/packages/propel/src/components/text-area/text-area.stories.tsx b/packages/propel/src/components/text-area/text-area.stories.tsx index b3d97b2f..5239b80d 100644 --- a/packages/propel/src/components/text-area/text-area.stories.tsx +++ b/packages/propel/src/components/text-area/text-area.stories.tsx @@ -42,7 +42,9 @@ export const FieldComposition: Story = { args: { magnitude: "md", surface: "field", resize: "vertical", rows: 3 }, render: (args) => ( - Comment + + Comment +