From 052b9bc3a5f8e1d726ce2a3e7f593d655eaf5da5 Mon Sep 17 00:00:00 2001 From: interacsean Date: Mon, 5 Oct 2026 16:15:47 +1100 Subject: [PATCH 1/2] feat(card): add padding="none" to Card.Content for edge-to-edge content (#1390) Tables, DataTables and divided lists in a card needed a `px-0!` class override. The prop removes the padding, carries the card's corner radius to a direct Table/DataTable child so row backgrounds stay inside the rounded corners, and drops a nested DataTable's own border. Cells keep their intrinsic 24px inset, so the first column lines up with the title. Updates the document-detail and styling-theming docs that taught the workaround, and adds a Card showcase page to the vite example. Co-Authored-By: Claude Opus 5.5 --- .changeset/card-content-padding-none.md | 18 ++ docs-manifest.json | 30 +-- docs-src/components/card.docs.examples.tsx | 30 ++- docs-src/components/card.docs.outline.md | 56 +++- .../concepts/styling-theming.docs.outline.md | 6 +- .../pages/document-detail.docs.outline.md | 12 +- docs/components/card.md | 86 +++++- docs/concepts/styling-theming.md | 6 +- docs/pages/document-detail.md | 12 +- examples/vite-app/src/App.tsx | 1 + .../vite-app/src/pages/showcase/card/page.tsx | 252 ++++++++++++++++++ examples/vite-app/src/routes.generated.ts | 1 + .../src__components__card__card.test.tsx.snap | 4 +- .../core/src/components/card/card.test.tsx | 38 +++ packages/core/src/components/card/card.tsx | 27 +- 15 files changed, 529 insertions(+), 50 deletions(-) create mode 100644 .changeset/card-content-padding-none.md create mode 100644 examples/vite-app/src/pages/showcase/card/page.tsx diff --git a/.changeset/card-content-padding-none.md b/.changeset/card-content-padding-none.md new file mode 100644 index 000000000..e3c65c5ca --- /dev/null +++ b/.changeset/card-content-padding-none.md @@ -0,0 +1,18 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `padding="none"` to `Card.Content` for edge-to-edge content such as a `Table`, `DataTable` or divided list. + +```tsx + + + + + + + + +``` + +Cells keep their own 24px inset, so the first column lines up with the card title. Row backgrounds stay inside the card's rounded corners, and a nested `DataTable` drops its own border. This replaces the `className="px-0!"` workaround. diff --git a/docs-manifest.json b/docs-manifest.json index 63477342d..8ce00c786 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -241,10 +241,10 @@ "symbols": ["Card"], "hashes": { "typeSurface": "6ce6f0fdf2450cec", - "outline": "794fbe3b6440047a", - "snapshot": "98e09e5019fa1368", - "outputMd": "29b147ccc0c5ab88", - "examples": "b7b2d28f20fe0a18" + "outline": "a8c217856c9c367a", + "snapshot": "31ad44b3faa1da06", + "outputMd": "675dc665e5082ff0", + "examples": "b20054836086ced0" } }, "checkbox": { @@ -605,7 +605,7 @@ "hashes": { "typeSurface": "b1614970f950ceb6", "outline": "bda40376d6e3e008", - "snapshot": "d4fa33f2da19d5ee", + "snapshot": "b845d4c54453b0f0", "outputMd": "ec6a0ac098d1d87a", "examples": "934c52d4e8761404" } @@ -621,9 +621,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "41d9abcae59b27b4", + "outline": "ead7d94daf36e8da", "snapshot": null, - "outputMd": "c3fb2588a0699c26", + "outputMd": "717ef4e7ae835aff", "examples": null } }, @@ -1013,7 +1013,7 @@ "hashes": { "typeSurface": "943616def28cafb0", "outline": "8f06df28129676c8", - "snapshot": "02dfd02003105939", + "snapshot": "6233cd9de511210f", "outputMd": "ceb4a05822538d20", "examples": "cff0508bb5520ae6" } @@ -1166,7 +1166,7 @@ "hashes": { "typeSurface": "429710973dfa912b", "outline": "721e8995f53f0e8f", - "snapshot": "f6fcbd73e127f2f4", + "snapshot": "0ceac9786fb4ce3c", "outputMd": "ae287e2bb5abc1c6", "examples": "1ce89cec30045466" } @@ -1183,7 +1183,7 @@ "hashes": { "typeSurface": "e760949efcbe4c38", "outline": "464d91acc48f7c9d", - "snapshot": "12c20fc6f44d1b4c", + "snapshot": "40d15c3f1003fdb0", "outputMd": "1f81ebd562c50371", "examples": "b0eebb8c5e2219dc" } @@ -1284,9 +1284,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "96d4bfc1c2681154", + "outline": "cfb216313ceef95b", "snapshot": null, - "outputMd": "0133fdae405f467c", + "outputMd": "e49df6a34ffb844e", "examples": "d3cca347b055a503" } }, @@ -1656,7 +1656,7 @@ "packages/core/skills/app-shell-patterns/references/concepts/modules-and-resources.md": "bf15b5460f22d165", "packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "2f3ff3f095c2b1cc", "packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "dc6b3afc049b4148", - "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "0133fdae405f467c", + "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "e49df6a34ffb844e", "packages/core/skills/app-shell-patterns/references/components/action-panel.md": "71794d4b561e4d4b", "packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0", "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194", @@ -1668,7 +1668,7 @@ "packages/core/skills/app-shell-patterns/references/components/avatar.md": "a874b74c176bcb47", "packages/core/skills/app-shell-patterns/references/components/badge.md": "0cb27fbce6f76e2e", "packages/core/skills/app-shell-patterns/references/components/button.md": "bce0e2fada1d1638", - "packages/core/skills/app-shell-patterns/references/components/card.md": "29b147ccc0c5ab88", + "packages/core/skills/app-shell-patterns/references/components/card.md": "675dc665e5082ff0", "packages/core/skills/app-shell-patterns/references/components/checkbox.md": "14f7ba23f441df38", "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", @@ -1734,7 +1734,7 @@ "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "09e99e5035da6e81", "packages/core/skills/app-shell-patterns/references/patterns/interaction-toast.md": "f68bdab855730f48", "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "8062ea0df9403fbb", - "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "c3fb2588a0699c26", + "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff", "packages/core/skills/app-shell-patterns/references/migrations.md": "20fb5d88b76dd933", "packages/core/skills/app-shell-patterns/SKILL.md": "814325f4ea06ffae" } diff --git a/docs-src/components/card.docs.examples.tsx b/docs-src/components/card.docs.examples.tsx index ebbe349c4..8ffadac8b 100644 --- a/docs-src/components/card.docs.examples.tsx +++ b/docs-src/components/card.docs.examples.tsx @@ -1,4 +1,4 @@ -import { Card } from "@tailor-platform/app-shell"; +import { Card, Table } from "@tailor-platform/app-shell"; export function BasicUsage() { return ( @@ -10,3 +10,31 @@ export function BasicUsage() { ); } + +export function EdgeToEdgeTable() { + return ( + + + + + + + Item + Qty + + + + + Steel bracket + 12 + + + Hex bolt M8 + 40 + + + + + + ); +} diff --git a/docs-src/components/card.docs.outline.md b/docs-src/components/card.docs.outline.md index 4d95e690d..fac61a817 100644 --- a/docs-src/components/card.docs.outline.md +++ b/docs-src/components/card.docs.outline.md @@ -27,7 +27,7 @@ import { Card } from "@tailor-platform/app-shell"; | -------------- | ---------------------------------------------------------------- | | `Card.Root` | Outer container. Renders a `
` with surface styling applied. | | `Card.Header` | Top section with an optional title and description. | -| `Card.Content` | Body section with consistent horizontal and bottom padding. | +| `Card.Content` | Body section with consistent padding, or edge-to-edge. | ## Card.Root Props @@ -51,10 +51,52 @@ import { Card } from "@tailor-platform/app-shell"; `Card.Content` accepts all standard HTML `
` props. -| Prop | Type | Default | Description | -| ----------- | ----------------- | ------- | ---------------------- | -| `className` | `string` | - | Additional CSS classes | -| `children` | `React.ReactNode` | - | Card body content | +| Prop | Type | Default | Description | +| ----------- | ----------------------- | ----------- | ---------------------------------------------------------------------------------------------- | +| `padding` | `"default"` \| `"none"` | `"default"` | `"none"` renders the content edge-to-edge — see [Edge-to-edge content](#edge-to-edge-content). | +| `className` | `string` | - | Additional CSS classes | +| `children` | `React.ReactNode` | - | Card body content | + +## Edge-to-edge content + +Use `padding="none"` when a `Table`, `DataTable` or divided list should meet the card's border instead of floating inside it: + + + +Don't add padding back on the table. `Table.Head` and `Table.Cell` already inset their first and last cells by 24px, which lines the first column up with the card title. + +Edge-to-edge content also: + +- **Keeps row backgrounds inside the card's rounded corners.** A direct `Table` or `DataTable` child clips its row hover and pinned-cell backgrounds to the card's corners. +- **Removes a nested `DataTable`'s own border**, so it doesn't draw a box inside the card. Its toolbar and footer stay as they are: + +```tsx + + + + + + + + + + + +``` + +For a scrollable list, put the overflow on `Card.Content`. Rows supply their own `px-6`, and dividers run to the card edges: + +```tsx + +
    + {items.map((item) => ( +
  • + {item.name} +
  • + ))} +
+
+``` ## Examples @@ -85,8 +127,8 @@ import { Card } from "@tailor-platform/app-shell"; View all - - {/* ... */}
+ + {/* ... */} ``` diff --git a/docs-src/concepts/styling-theming.docs.outline.md b/docs-src/concepts/styling-theming.docs.outline.md index ffe7b0f85..5e77a98d7 100644 --- a/docs-src/concepts/styling-theming.docs.outline.md +++ b/docs-src/concepts/styling-theming.docs.outline.md @@ -445,12 +445,12 @@ Write **plain Tailwind utilities** in your application code. Three rules cover e
``` -When no prop exists and you genuinely have to override a value a component sets, add Tailwind's [importance modifier](https://tailwindcss.com/docs/styling-with-utility-classes#important-modifier) — a trailing `!`. It is the last resort, not the first: a plain utility silently loses (see below), and `!` is what the pattern catalogue uses for the one documented case, zeroing a card's padding so a table can sit flush inside it: +When no prop exists and you genuinely have to override a value a component sets, add Tailwind's [importance modifier](https://tailwindcss.com/docs/styling-with-utility-classes#important-modifier) — a trailing `!`. It is the last resort, not the first: a plain utility silently loses (see below). Check for a prop before reaching for it. Sitting a table flush inside a card, for example, used to need `className="px-0!"`; it is now a prop: ```tsx - + {/* … */} @@ -477,6 +477,8 @@ This is the reason rule 3 sends you to props rather than to a more specific clas ``` +(For this particular case, use `padding="none"`.) + State variants make it worse rather than better: `Button`'s `ghost` variant sets a hover color, and a `:hover` rule outranks an unprefixed utility on **specificity**, not merely order — so a plain `text-destructive` on a ghost button is red only until the pointer reaches it. `variant="destructive"` is the supported way to express that; where no such prop exists, `!` wins in every state because importance beats specificity. ### Styling by state (data attributes) diff --git a/docs-src/pages/document-detail.docs.outline.md b/docs-src/pages/document-detail.docs.outline.md index 95455ded0..2948b15da 100644 --- a/docs-src/pages/document-detail.docs.outline.md +++ b/docs-src/pages/document-detail.docs.outline.md @@ -209,7 +209,7 @@ time, and the record is unblocked only once every one is released. ### 4. Line items `Card.Root` → `Card.Header` (title, plus a one-line description where the -columns need explaining) → `Card.Content className="px-0!"` → `Table.Root`. +columns need explaining) → `Card.Content padding="none"` → `Table.Root`. Fetch the line items with the record, sorted explicitly so the order is stable across reloads, and render them as a plain `Table`. For the typical document — @@ -799,7 +799,7 @@ export default function PurchaseOrderDetailPage({ would have been a link field on the summary instead. */} - + @@ -834,7 +834,7 @@ export default function PurchaseOrderDetailPage({ enough to page. No container padding; the cells inset themselves. */} - + {order.lineItems.length === 0 ? (

No line items on this order.

) : ( @@ -901,7 +901,7 @@ export default function PurchaseOrderDetailPage({ relationship is possible, so the reader learns it exists. */} - + {order.goodsReceipts.length === 0 ? (

No goods receipts linked to this order. @@ -1023,7 +1023,7 @@ export default function PurchaseOrderDetailPage({ title="Journal entry" description="Booked when this order was posted." /> - + @@ -1122,7 +1122,7 @@ enforced across every detail screen once settled. ## Constraints - **Every main-column section sits in a `Card.Root`** — except `DescriptionCard`, which contains itself, and `Alert`, which is a banner. No bare `

` sections. -- **A table in a card needs ONE geometry change, not two.** Zero the card's padding (`Card.Content className="px-0!"`, or drop `Card.Content`) and leave the table container alone. `Table.Head` and `Table.Cell` already inset their own first and last cells by 24px; padding on the table container stacks on top of that and pushes the first column 24px right of the card title. +- **A table in a card goes in `Card.Content padding="none"`, and nothing else changes.** Don't drop `Card.Content` or zero its padding with a class instead: the prop also keeps row backgrounds inside the card's rounded corners and removes a nested `DataTable`'s own border. Leave the table container alone too. `Table.Head` and `Table.Cell` already inset their own first and last cells by 24px; padding on the table container stacks on top of that and pushes the first column 24px right of the card title. - **Bare `YYYY-MM-DD` dates must not use `type: "date"`.** `DescriptionCard` hands the value to `new Date(...)`, which reads a date-only string as UTC midnight and renders the previous day west of Greenwich. Pre-format those as text via `render`. Real timestamps keep `type: "date"`, with `emptyBehavior: "hide"` when nullable. - **`ActionPanel` is workflow-only.** No navigation, no "view related record" — those are links in the cards that hold them. - **Never rely on a query cap to bound the line-items table.** If the cap can be reached, page the lines. diff --git a/docs/components/card.md b/docs/components/card.md index 1abec45e3..ad2f19b39 100644 --- a/docs/components/card.md +++ b/docs/components/card.md @@ -38,7 +38,7 @@ function BasicUsage() { | -------------- | ---------------------------------------------------------------- | | `Card.Root` | Outer container. Renders a `
` with surface styling applied. | | `Card.Header` | Top section with an optional title and description. | -| `Card.Content` | Body section with consistent horizontal and bottom padding. | +| `Card.Content` | Body section with consistent padding, or edge-to-edge. | ## Card.Root Props @@ -62,10 +62,82 @@ function BasicUsage() { `Card.Content` accepts all standard HTML `
` props. -| Prop | Type | Default | Description | -| ----------- | ----------------- | ------- | ---------------------- | -| `className` | `string` | - | Additional CSS classes | -| `children` | `React.ReactNode` | - | Card body content | +| Prop | Type | Default | Description | +| ----------- | ----------------------- | ----------- | ---------------------------------------------------------------------------------------------- | +| `padding` | `"default"` \| `"none"` | `"default"` | `"none"` renders the content edge-to-edge — see [Edge-to-edge content](#edge-to-edge-content). | +| `className` | `string` | - | Additional CSS classes | +| `children` | `React.ReactNode` | - | Card body content | + +## Edge-to-edge content + +Use `padding="none"` when a `Table`, `DataTable` or divided list should meet the card's border instead of floating inside it: + + + +```tsx +function EdgeToEdgeTable() { + return ( + + + + + + + Item + Qty + + + + + Steel bracket + 12 + + + Hex bolt M8 + 40 + + + + + + ); +} +``` + +Don't add padding back on the table. `Table.Head` and `Table.Cell` already inset their first and last cells by 24px, which lines the first column up with the card title. + +Edge-to-edge content also: + +- **Keeps row backgrounds inside the card's rounded corners.** A direct `Table` or `DataTable` child clips its row hover and pinned-cell backgrounds to the card's corners. +- **Removes a nested `DataTable`'s own border**, so it doesn't draw a box inside the card. Its toolbar and footer stay as they are: + +```tsx + + + + + + + + + + + +``` + +For a scrollable list, put the overflow on `Card.Content`. Rows supply their own `px-6`, and dividers run to the card edges: + +```tsx + +
    + {items.map((item) => ( +
  • + {item.name} +
  • + ))} +
+
+``` ## Examples @@ -96,8 +168,8 @@ function BasicUsage() { View all - - {/* ... */}
+ + {/* ... */} ``` diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 8cf735cf9..c51c014d2 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -462,12 +462,12 @@ Write **plain Tailwind utilities** in your application code. Three rules cover e
``` -When no prop exists and you genuinely have to override a value a component sets, add Tailwind's [importance modifier](https://tailwindcss.com/docs/styling-with-utility-classes#important-modifier) — a trailing `!`. It is the last resort, not the first: a plain utility silently loses (see below), and `!` is what the pattern catalogue uses for the one documented case, zeroing a card's padding so a table can sit flush inside it: +When no prop exists and you genuinely have to override a value a component sets, add Tailwind's [importance modifier](https://tailwindcss.com/docs/styling-with-utility-classes#important-modifier) — a trailing `!`. It is the last resort, not the first: a plain utility silently loses (see below). Check for a prop before reaching for it. Sitting a table flush inside a card, for example, used to need `className="px-0!"`; it is now a prop: ```tsx - + {/* … */} @@ -494,6 +494,8 @@ This is the reason rule 3 sends you to props rather than to a more specific clas ``` +(For this particular case, use `padding="none"`.) + State variants make it worse rather than better: `Button`'s `ghost` variant sets a hover color, and a `:hover` rule outranks an unprefixed utility on **specificity**, not merely order — so a plain `text-destructive` on a ghost button is red only until the pointer reaches it. `variant="destructive"` is the supported way to express that; where no such prop exists, `!` wins in every state because importance beats specificity. ### Styling by state (data attributes) diff --git a/docs/pages/document-detail.md b/docs/pages/document-detail.md index 8ba453dc2..51192cf46 100644 --- a/docs/pages/document-detail.md +++ b/docs/pages/document-detail.md @@ -183,7 +183,7 @@ time, and the record is unblocked only once every one is released. ### 4. Line items `Card.Root` → `Card.Header` (title, plus a one-line description where the -columns need explaining) → `Card.Content className="px-0!"` → `Table.Root`. +columns need explaining) → `Card.Content padding="none"` → `Table.Root`. Fetch the line items with the record, sorted explicitly so the order is stable across reloads, and render them as a plain `Table`. For the typical document — @@ -773,7 +773,7 @@ export default function PurchaseOrderDetailPage({ would have been a link field on the summary instead. */} - + @@ -808,7 +808,7 @@ export default function PurchaseOrderDetailPage({ enough to page. No container padding; the cells inset themselves. */} - + {order.lineItems.length === 0 ? (

No line items on this order.

) : ( @@ -875,7 +875,7 @@ export default function PurchaseOrderDetailPage({ relationship is possible, so the reader learns it exists. */} - + {order.goodsReceipts.length === 0 ? (

No goods receipts linked to this order. @@ -997,7 +997,7 @@ export default function PurchaseOrderDetailPage({ title="Journal entry" description="Booked when this order was posted." /> - + @@ -1096,7 +1096,7 @@ enforced across every detail screen once settled. ## Constraints - **Every main-column section sits in a `Card.Root`** — except `DescriptionCard`, which contains itself, and `Alert`, which is a banner. No bare `

` sections. -- **A table in a card needs ONE geometry change, not two.** Zero the card's padding (`Card.Content className="px-0!"`, or drop `Card.Content`) and leave the table container alone. `Table.Head` and `Table.Cell` already inset their own first and last cells by 24px; padding on the table container stacks on top of that and pushes the first column 24px right of the card title. +- **A table in a card goes in `Card.Content padding="none"`, and nothing else changes.** Don't drop `Card.Content` or zero its padding with a class instead: the prop also keeps row backgrounds inside the card's rounded corners and removes a nested `DataTable`'s own border. Leave the table container alone too. `Table.Head` and `Table.Cell` already inset their own first and last cells by 24px; padding on the table container stacks on top of that and pushes the first column 24px right of the card title. - **Bare `YYYY-MM-DD` dates must not use `type: "date"`.** `DescriptionCard` hands the value to `new Date(...)`, which reads a date-only string as UTC midnight and renders the previous day west of Greenwich. Pre-format those as text via `render`. Real timestamps keep `type: "date"`, with `emptyBehavior: "hide"` when nullable. - **`ActionPanel` is workflow-only.** No navigation, no "view related record" — those are links in the cards that hold them. - **Never rely on a query cap to bound the line-items table.** If the cap can be reached, page the lines. diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index cc372536d..353ec56fb 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -66,6 +66,7 @@ const AppInner = () => { + diff --git a/examples/vite-app/src/pages/showcase/card/page.tsx b/examples/vite-app/src/pages/showcase/card/page.tsx new file mode 100644 index 000000000..bd6aa8210 --- /dev/null +++ b/examples/vite-app/src/pages/showcase/card/page.tsx @@ -0,0 +1,252 @@ +import { useMemo, type ReactNode } from "react"; +import { + Layout, + Badge, + Card, + DataTable, + Table, + useDataTable, + useCollectionVariables, + createColumnHelper, + type CollectionVariables, + type DataTableData, + type AppShellPageProps, +} from "@tailor-platform/app-shell"; +import { SquareDashed } from "lucide-react"; + +// ─── Data ────────────────────────────────────────────────────────────────────── + +type Line = { + id: string; + sku: string; + description: string; + qty: number; + unitPrice: number; + status: "open" | "shipped" | "backordered"; +}; + +const DESCRIPTIONS = [ + "Steel bracket, 40mm", + "Hex bolt M8 × 30", + "Nylon washer, 8mm", + "Aluminium extrusion, 2m", + "Rubber gasket, 120mm", + "Cable tie, 300mm (100 pk)", +]; +const STATUSES: Line["status"][] = ["open", "shipped", "backordered"]; + +const LINES: Line[] = Array.from({ length: 23 }, (_, i) => ({ + id: `L-${String(i + 1).padStart(3, "0")}`, + sku: `SKU-${4100 + i * 7}`, + description: DESCRIPTIONS[i % DESCRIPTIONS.length], + qty: ((i * 37) % 90) + 1, + unitPrice: Math.round((((i * 53) % 400) + 4.5) * 100) / 100, + status: STATUSES[i % STATUSES.length], +})); + +const money = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" }); + +const statusVariant = (status: Line["status"]) => + status === "shipped" + ? ("success" as const) + : status === "backordered" + ? ("outline-warning" as const) + : ("outline-info" as const); + +const { column } = createColumnHelper(); + +const columns = [ + column({ label: "Line", render: (row) => row.id }), + column({ label: "SKU", render: (row) => {row.sku} }), + column({ label: "Description", render: (row) => row.description }), + column({ + label: "Status", + render: (row) => {row.status}, + }), + column({ label: "Qty", align: "right", render: (row) => row.qty }), + column({ + label: "Total", + align: "right", + render: (row) => money.format(row.qty * row.unitPrice), + }), +]; + +// Cursor pagination over the static rows (cursor = stringified row index). +function paginate(rows: Line[], { pagination }: CollectionVariables): DataTableData { + const { first, after, last, before } = pagination; + const total = rows.length; + const start = + last != null + ? Math.max(0, (before != null ? Number(before) : total) - last) + : after != null + ? Number(after) + 1 + : 0; + const page = rows.slice(start, start + (last ?? first ?? 5)); + return { + rows: page, + total, + pageInfo: { + startCursor: page.length ? String(start) : null, + endCursor: page.length ? String(start + page.length - 1) : null, + hasPreviousPage: start > 0, + hasNextPage: start + page.length < total, + }, + }; +} + +// ─── Examples ────────────────────────────────────────────────────────────────── + +function PaginatedLines() { + const { variables, control } = useCollectionVariables({ params: { pageSize: 5 } }); + const data = useMemo(() => paginate(LINES, variables), [variables]); + const table = useDataTable({ columns, data, control }); + + return ( + + + + + + + + + + + + ); +} + +function LastRowLines() { + // Pinned right column: its opaque cell background is what would poke past the + // card's rounded bottom-right corner if the clip were missing. + const table = useDataTable({ + columns: columns.map((c, i) => + i === columns.length - 1 ? { ...c, pin: "right" as const } : c, + ), + data: { rows: LINES.slice(0, 4), total: 4 }, + }); + + return ( + + + + + + + + + ); +} + +function HeaderlessLines() { + const table = useDataTable({ columns, data: { rows: LINES.slice(0, 3), total: 3 } }); + + return ( + + + + + + + + ); +} + +function PlainTableLines() { + return ( + + + + + + + Line + Description + Total + + + + {LINES.slice(0, 4).map((line) => ( + + {line.id} + {line.description} + {money.format(line.qty * line.unitPrice)} + + ))} + + + + + ); +} + +function ScrollingList() { + return ( + + + +
    + {LINES.map((line) => ( +
  • + {line.description} + {line.qty} pcs +
  • + ))} +
+
+
+ ); +} + +function Example({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +// ─── Page ────────────────────────────────────────────────────────────────────── + +const CardDemoPage = () => ( + + + +

+ Card.Content padding="none" renders tables and lists edge-to-edge. Cells keep + their own 24px inset, so the first column lines up with the card title. +

+ + + + + + + + + + + + + + + +
+
+); + +CardDemoPage.appShellPageProps = { + meta: { + title: "Card Demo", + icon: , + }, +} satisfies AppShellPageProps; + +export default CardDemoPage; diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index 3f50e86a4..82218a12e 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -27,6 +27,7 @@ export type GeneratedRouteParams = { "/showcase/activity-card": {}; "/showcase/ai-chat": {}; "/showcase/alert-tokens": {}; + "/showcase/card": {}; "/showcase/colors": {}; "/showcase/csv-importer": {}; "/showcase/data-table": {}; diff --git a/packages/core/__snapshots__/src__components__card__card.test.tsx.snap b/packages/core/__snapshots__/src__components__card__card.test.tsx.snap index d398b3785..71a0a061c 100644 --- a/packages/core/__snapshots__/src__components__card__card.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__card__card.test.tsx.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Card > snapshots > basic card with header and content 1`] = `"

Title

Description
Content
"`; +exports[`Card > snapshots > basic card with header and content 1`] = `"

Title

Description
Content
"`; -exports[`Card > snapshots > card with custom className 1`] = `"
Content
"`; +exports[`Card > snapshots > card with custom className 1`] = `"
Content
"`; diff --git a/packages/core/src/components/card/card.test.tsx b/packages/core/src/components/card/card.test.tsx index e2bddffcb..987777729 100644 --- a/packages/core/src/components/card/card.test.tsx +++ b/packages/core/src/components/card/card.test.tsx @@ -46,4 +46,42 @@ describe("Card", () => { ); expect(screen.getByText("Card body")).toBeDefined(); }); + + describe("Content padding", () => { + it("pads content by default", () => { + render( + + Body + , + ); + const content = screen.getByText("Body"); + expect(content.dataset.padding).toBe("default"); + expect(content.className).toContain("astw:px-6"); + }); + + it('renders edge-to-edge with padding="none"', () => { + render( + + Body + , + ); + const content = screen.getByText("Body"); + expect(content.dataset.padding).toBe("none"); + expect(content.className).not.toMatch(/astw:(px|pb|first:pt)-6/); + // Corners are carried, not clipped: a consumer's overflow still applies. + expect(content.className).toContain("astw:last:rounded-b-[inherit]"); + expect(content.className).not.toContain("overflow"); + }); + + it("strips a nested DataTable's own border", () => { + render( + + Body + , + ); + expect(screen.getByText("Body").className).toContain( + "astw:[&>[data-slot=data-table]]:border-0", + ); + }); + }); }); diff --git a/packages/core/src/components/card/card.tsx b/packages/core/src/components/card/card.tsx index 31004c063..e48e61975 100644 --- a/packages/core/src/components/card/card.tsx +++ b/packages/core/src/components/card/card.tsx @@ -50,11 +50,34 @@ function Header({ className, title, description, children, ...props }: HeaderPro } Header.displayName = "Card.Header"; -function Content({ className, ...props }: React.ComponentProps<"div">) { +type ContentProps = React.ComponentProps<"div"> & { + /** + * `"none"` renders the content edge-to-edge, for a `Table`, `DataTable` or + * divided list that should meet the card's border. + * @default "default" + */ + padding?: "default" | "none"; +}; + +// Flush content carries the card's corner radius (no overflow of its own, so a +// consumer's `overflow-y-auto` still works) and hands it to a direct table +// child, whose own overflow clipping keeps row backgrounds inside the corners. +// A nested DataTable drops its border so it does not draw a box in the box. +const flushClassName = cn( + "astw:first:rounded-t-[inherit] astw:last:rounded-b-[inherit]", + "astw:[&>[data-slot=table-container]]:rounded-[inherit]", + "astw:[&>[data-slot=data-table]]:rounded-[inherit] astw:[&>[data-slot=data-table]]:border-0", +); + +function Content({ className, padding = "default", ...props }: ContentProps) { return (
); From b8df152f92ca6caa9dc3e228d2d4b3d823fa9efc Mon Sep 17 00:00:00 2001 From: interacsean Date: Mon, 5 Oct 2026 16:19:30 +1100 Subject: [PATCH 2/2] docs(card): add basic Card compositions to the showcase page Co-Authored-By: Claude Opus 5.5 --- .../vite-app/src/pages/showcase/card/page.tsx | 85 ++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/examples/vite-app/src/pages/showcase/card/page.tsx b/examples/vite-app/src/pages/showcase/card/page.tsx index bd6aa8210..3ba35b370 100644 --- a/examples/vite-app/src/pages/showcase/card/page.tsx +++ b/examples/vite-app/src/pages/showcase/card/page.tsx @@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from "react"; import { Layout, Badge, + Button, Card, DataTable, Table, @@ -94,7 +95,77 @@ function paginate(rows: Line[], { pagination }: CollectionVariables): DataTableD }; } -// ─── Examples ────────────────────────────────────────────────────────────────── +// ─── Basics ──────────────────────────────────────────────────────────────────── + +function BasicCards() { + return ( +
+ + + +

Content is padded 24px on the sides and bottom.

+
+
+ + + + + + + +

+ Content only. With no header above it, the content also gets 24px top padding. +

+
+
+ + + +
+ +
+
+ +

Header children render below the title and description.

+
+
+ + + + +

Content sits below the divider.

+
+
+ + + + +

Backordered lines ship separately.

+
+ +
    + {LINES.filter((line) => line.status === "backordered") + .slice(0, 3) + .map((line) => ( +
  • + {line.description} + {line.qty} pcs +
  • + ))} +
+
+
+
+ ); +} + +// ─── Edge-to-edge examples ───────────────────────────────────────────────────── function PaginatedLines() { const { variables, control } = useCollectionVariables({ params: { pageSize: 5 } }); @@ -219,7 +290,17 @@ const CardDemoPage = () => ( -

+

Basics

+

+ Card.Root, Card.Header and Card.Content in their + common combinations. +

+
+ +
+ +

Edge-to-edge content

+

Card.Content padding="none" renders tables and lists edge-to-edge. Cells keep their own 24px inset, so the first column lines up with the card title.