, "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]");
diff --git a/packages/propel/src/ui/tabs/index.tsx b/packages/propel/src/ui/tabs/index.tsx
index e159a094..063ba359 100644
--- a/packages/propel/src/ui/tabs/index.tsx
+++ b/packages/propel/src/ui/tabs/index.tsx
@@ -1,5 +1,6 @@
export * from "./tab";
export * from "./tab-underline-bar";
+export * from "./tab-underline-bar-track";
export * from "./tab-underline-label";
export * from "./tabs";
export * from "./tabs-indicator";
diff --git a/packages/propel/src/ui/tabs/tab-underline-bar-track.tsx b/packages/propel/src/ui/tabs/tab-underline-bar-track.tsx
new file mode 100644
index 00000000..7bbeba6b
--- /dev/null
+++ b/packages/propel/src/ui/tabs/tab-underline-bar-track.tsx
@@ -0,0 +1,17 @@
+import type * as React from "react";
+
+import { underlineBarTrackVariants } from "./variants";
+
+export type TabUnderlineBarTrackProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * The padded track that holds an `underline`-appearance tab's sliding bar. Renders its bar child
+ * via `{...props}` so the underline track cva stays internal; compose `` inside
+ * it.
+ */
+export function TabUnderlineBarTrack(props: TabUnderlineBarTrackProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/tabs/tab-underline-bar.tsx b/packages/propel/src/ui/tabs/tab-underline-bar.tsx
index 313baacf..69c7a73d 100644
--- a/packages/propel/src/ui/tabs/tab-underline-bar.tsx
+++ b/packages/propel/src/ui/tabs/tab-underline-bar.tsx
@@ -1,14 +1,17 @@
-import { underlineBarTrackVariants, underlineBarVariants } from "./variants";
+import type * as React from "react";
+
+import { underlineBarVariants } from "./variants";
+
+export type TabUnderlineBarProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
/**
- * The sliding underline beneath an `underline`-appearance tab's label: a padded track containing
- * the decorative bar that tints on hover and goes transparent when active (the shared
- * `TabsIndicator` takes over). Owns both styled ``s so the underline cva stays internal.
+ * The decorative bar beneath an `underline`-appearance tab's label: tints on hover and goes
+ * transparent when active (the shared `TabsIndicator` takes over). Sits inside a
+ * `TabUnderlineBarTrack`. Owns the styled `` so the underline cva stays internal.
*/
-export function TabUnderlineBar() {
- return (
-
-
-
- );
+export function TabUnderlineBar(props: TabUnderlineBarProps) {
+ return ;
}
diff --git a/packages/propel/src/ui/tabs/tabs.stories.tsx b/packages/propel/src/ui/tabs/tabs.stories.tsx
index 9274b3af..22f28a2b 100644
--- a/packages/propel/src/ui/tabs/tabs.stories.tsx
+++ b/packages/propel/src/ui/tabs/tabs.stories.tsx
@@ -4,6 +4,7 @@ import { expect } from "storybook/test";
import {
Tab,
TabUnderlineBar,
+ TabUnderlineBarTrack,
TabUnderlineLabel,
Tabs,
TabsIndicator,
@@ -61,7 +62,8 @@ export const Default: Story = {
/**
* The underline appearance: compose the shared `TabsIndicator` inside the `TabsList` (the
* ready-made `components/tabs` adds it for you). Each `Tab` decorates its body with the atomic
- * `TabUnderlineLabel` (the rounded label box) and `TabUnderlineBar` (the per-tab hover bar).
+ * `TabUnderlineLabel` (the rounded label box) and a `TabUnderlineBarTrack` wrapping a
+ * `TabUnderlineBar` (the padded track plus the per-tab hover bar).
*/
export const Underline: Story = {
render: () => (
@@ -70,7 +72,9 @@ export const Underline: Story = {
{TAB_ITEMS.map((item) => (
{item.label}
-
+
+
+
))}
diff --git a/packages/propel/src/ui/text-area/text-area-box.tsx b/packages/propel/src/ui/text-area/text-area-box.tsx
index 7c9f22fc..eadcaaec 100644
--- a/packages/propel/src/ui/text-area/text-area-box.tsx
+++ b/packages/propel/src/ui/text-area/text-area-box.tsx
@@ -1,19 +1,16 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
-import { textAreaBoxVariants, type TextAreaTone } from "./variants";
+import { textAreaBoxVariants } from "./variants";
-export type TextAreaBoxProps = Omit, "className" | "style"> & {
- /** Resting treatment. `neutral` | `danger` (the Figma "error" state). */
- tone: TextAreaTone;
-};
+export type TextAreaBoxProps = Omit, "className" | "style">;
/**
* The bordered frame that wraps a `TextArea` leaf. Owns the border, radius, padding, and the
* focus/error border treatments so a standalone textarea has the same chrome as one inside a
* `Field`. Place a single `TextArea` (and any inline affordances) as its children.
*/
-export function TextAreaBox({ tone, render, ...props }: TextAreaBoxProps) {
- const defaultProps: useRender.ElementProps<"div"> = { className: textAreaBoxVariants({ tone }) };
+export function TextAreaBox({ render, ...props }: TextAreaBoxProps) {
+ const defaultProps: useRender.ElementProps<"div"> = { className: textAreaBoxVariants() };
return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) });
}
diff --git a/packages/propel/src/ui/text-area/text-area.stories.tsx b/packages/propel/src/ui/text-area/text-area.stories.tsx
index 4a530c03..d6947a47 100644
--- a/packages/propel/src/ui/text-area/text-area.stories.tsx
+++ b/packages/propel/src/ui/text-area/text-area.stories.tsx
@@ -35,7 +35,7 @@ export const Default: Story = {
rows: 4,
},
render: (args) => (
-
+
| | | |