From 69bbb290ba42213aecb9a4e0bd2192d6ca93fa88 Mon Sep 17 00:00:00 2001 From: interacsean Date: Thu, 2 Jul 2026 16:06:29 +1000 Subject: [PATCH 1/2] feat: DataTable internal scrolling with pinned chrome via Layout fill Implements internal scrolling for DataTable inside the AppShell layout (tailor-inc/platform-planning#1388): - Shell is now viewport-bounded (h-svh) with the content area taking over scroll duty (overflow-y-auto, full-bleed so the scrollbar sits at the window edge), so scrolling never happens on the document - New opt-in `fill` prop on Layout stretches the page to the available height and bounds the column row (grid minmax(0,1fr)) so children can scroll internally - DataTable.Root becomes a shrinkable flex column; the table container is the vertical scroll region with a sticky column header row; Toolbar and Footer stay pinned - Empty/error state height fixed at 3 rows (was pageSize-worth, creating a huge blank/scrollable region at large page sizes); message is sticky so it stays in view in height-constrained containers - Skeleton loader rows derive their height from the same structure as real rows (icon-button footprint, text line box, badge pills) so the table no longer shifts when data resolves Examples: products page uses with ~200 mock rows and a 25-row default page size; new long-content page verifies content-area scrolling for pages without fill. Pattern docs (list/dense-scan) and Layout docs updated and regenerated. Co-Authored-By: Claude Opus 4.8 --- .changeset/pinned-table-scroll.md | 26 +++ catalogue/src/fundamental/components.md | 4 +- .../src/pattern/list/dense-scan/PATTERN.md | 13 ++ .../pattern/list/dense-scan/dense-scan.tsx | 36 +++- docs/components/layout.md | 44 ++++- examples/vite-app/src/mock-products.ts | 182 +++++++++++++++++- .../src/pages/dashboard/long-content/page.tsx | 72 +++++++ .../src/pages/dashboard/products/page.tsx | 8 +- examples/vite-app/src/routes.generated.ts | 1 + .../references/fundamental/components.md | 4 +- .../references/fundamental/graphql.md | 2 +- .../references/patterns/list-dense-scan.md | 63 ++++-- packages/core/src/components/badge-list.tsx | 5 +- .../src/components/data-table/data-table.tsx | 86 +++++++-- .../src/components/data-table/toolbar.tsx | 12 +- .../src/components/layout/Layout.test.tsx | 36 ++++ .../core/src/components/layout/Layout.tsx | 26 ++- packages/core/src/components/layout/types.ts | 14 ++ packages/core/src/components/sidebar.tsx | 5 +- .../src/components/sidebar/sidebar-layout.tsx | 23 ++- 20 files changed, 587 insertions(+), 75 deletions(-) create mode 100644 .changeset/pinned-table-scroll.md create mode 100644 examples/vite-app/src/pages/dashboard/long-content/page.tsx diff --git a/.changeset/pinned-table-scroll.md b/.changeset/pinned-table-scroll.md new file mode 100644 index 00000000..4f54ec97 --- /dev/null +++ b/.changeset/pinned-table-scroll.md @@ -0,0 +1,26 @@ +--- +"@tailor-platform/app-shell": minor +--- + +DataTable now scrolls internally: add `fill` prop to `Layout` to pin page chrome and scroll only the table rows. + +```tsx + + + + + + + + + + + + +``` + +With `fill`, the page title, table toolbar, column header row (sticky), and footer stay visible at all heights — only the rows region scrolls vertically. Without `fill`, pages grow and scroll as before. Tables short enough to fit render without a scrollbar or layout shift. + +**Behavior change:** the AppShell layout is now viewport-bounded (`h-svh` instead of `min-h-svh`), so page content scrolls inside the content area rather than on the document. Code relying on `window`/document scroll position should target the content area instead. + +Also fixes the empty/error state reserving `pageSize`-worth of height — it is now capped at 5 rows, so large page sizes no longer produce a huge blank region below "No data". diff --git a/catalogue/src/fundamental/components.md b/catalogue/src/fundamental/components.md index a3070bdb..791f5033 100644 --- a/catalogue/src/fundamental/components.md +++ b/catalogue/src/fundamental/components.md @@ -47,7 +47,9 @@ For the full upstream API of any component, follow the link to the published ref **Import:** `import { Layout } from '@tailor-platform/app-shell'` **Purpose:** Standard page container with header + 1–N column body. The most-used component — every page wraps content in ``. -**API:** `Layout` (root) + `Layout.Column` + `Layout.Header`. `LayoutProps`: `columns` (number, default auto-detected from children), `gap`, `title`, `actions`, `className`, `style`. +**API:** `Layout` (root) + `Layout.Column` + `Layout.Header`. `LayoutProps`: `fill` (boolean), `columns` (number, default auto-detected from children), `gap`, `title`, `actions`, `className`, `style`. + +**`fill`** — stretches the layout to the available height and bounds the column row so children can scroll internally instead of growing the page. Use on table-first pages (`` + `DataTable`): the title, table toolbar, sticky column headers, and pagination footer stay pinned while only the rows scroll — see **`patterns/list-dense-scan.md`**. Omit on pages that should flow and scroll naturally (forms, dashboards). **Column-count → width rules** (column count is auto-detected from `Layout.Column` children): diff --git a/catalogue/src/pattern/list/dense-scan/PATTERN.md b/catalogue/src/pattern/list/dense-scan/PATTERN.md index c5eacf50..70041e50 100644 --- a/catalogue/src/pattern/list/dense-scan/PATTERN.md +++ b/catalogue/src/pattern/list/dense-scan/PATTERN.md @@ -45,6 +45,17 @@ dont: +## Page Layout & Internal Scrolling + +Table-first pages should pin their chrome and scroll only the rows region. Wrap the page in ``: + +- `fill` stretches the layout to the available height and bounds the column row, so the `DataTable` shrinks to fit instead of growing past the viewport +- The `Layout.Header` (title/actions), `DataTable.Toolbar`, the column header row (sticky), and `DataTable.Footer` (pagination) stay visible at every viewport height — only the rows scroll vertically +- When the current page of rows fits, nothing stretches and no scrollbar appears — short tables render identically with or without `fill` +- Requires no extra styling on the page: the height chain (`AppShell` content area → `Layout fill` → `Layout.Column` → `DataTable.Root`) is wired by the components + +Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, articles) — the AppShell content area scrolls those. + ## Variants - **Toolbar chips only (`DataTable.Filters`)** — best when filters map cleanly to typed column metadata / enum facets @@ -57,6 +68,7 @@ dont: - Column count: 4-8 recommended - Must include pagination — never render unbounded lists +- Table-first pages use `` so title/toolbar/header/footer stay pinned and only rows scroll - Status Badge colors must use design system tokens (variant prop) - Bulk actions toolbar appears only when ≥1 row is selected - Whole row is clickable via `onClickRow`; no per-row "View" / "Open" buttons @@ -65,6 +77,7 @@ dont: ## Anti-patterns - Building a bespoke table + custom pagination instead of `DataTable` + `useCollectionVariables` +- Hand-rolled `max-height`/`overflow` wrappers around `DataTable` to contain scrolling — use `` instead - Tabs that mutate only local UI state while pagination/filters assume the full server set - Using `` directly instead of `` for live collections - Client-side filtering on 1000+ records without server-side support diff --git a/catalogue/src/pattern/list/dense-scan/dense-scan.tsx b/catalogue/src/pattern/list/dense-scan/dense-scan.tsx index 8d1b4b08..80818cc4 100644 --- a/catalogue/src/pattern/list/dense-scan/dense-scan.tsx +++ b/catalogue/src/pattern/list/dense-scan/dense-scan.tsx @@ -1,5 +1,5 @@ /* pattern: list/dense-scan */ -import { DataTable, useDataTable, Button, Input } from "@tailor-platform/app-shell"; +import { DataTable, Layout, useDataTable, Button, Input } from "@tailor-platform/app-shell"; import type { Order } from "./columns"; import { columns } from "./columns"; import type { DataTableData } from "@tailor-platform/app-shell"; @@ -13,14 +13,30 @@ export default function DenseScanList({ data, onCreateClick }: Props) { const table = useDataTable({ data, columns }); return ( -
-
- - -
- - - -
+ // `fill` pins the page chrome for table-first pages: the title, toolbar, + // column header row, and pagination footer stay visible at every viewport + // height — only the table's rows region scrolls. Omit `fill` on pages + // that should flow and scroll naturally (forms, dashboards, articles). + + + Create Order + , + ]} + /> + + + + + + + + + + + + ); } diff --git a/docs/components/layout.md b/docs/components/layout.md index 582398de..42f63a94 100644 --- a/docs/components/layout.md +++ b/docs/components/layout.md @@ -34,15 +34,16 @@ The column count is auto-detected from the number of `Layout.Column` children: ### Layout Props -| Prop | Type | Default | Description | -| ----------- | ------------------- | ------------ | ------------------------------------------------------------------------------- | -| `className` | `string` | - | Additional CSS classes for container | -| `style` | `CSSProperties` | - | Inline styles for the layout container | -| `children` | `Layout.Column[]` | **Required** | `Layout.Header` and/or `Layout.Column` components | -| `columns` | `1 \| 2 \| 3` | - | **(Deprecated)** Auto-detected from `Layout.Column` children count when omitted | -| `title` | `string` | - | **(Deprecated)** Use `` instead | -| `actions` | `React.ReactNode[]` | - | **(Deprecated)** Use `` instead | -| `gap` | `number` | `4` | **(Deprecated)** Use `className` (e.g. `className="gap-6"`) instead | +| Prop | Type | Default | Description | +| ----------- | ------------------- | ------------ | --------------------------------------------------------------------------------------- | +| `className` | `string` | - | Additional CSS classes for container | +| `style` | `CSSProperties` | - | Inline styles for the layout container | +| `children` | `Layout.Column[]` | **Required** | `Layout.Header` and/or `Layout.Column` components | +| `fill` | `boolean` | `false` | Fill the available height instead of growing with content (see [Fill Mode](#fill-mode)) | +| `columns` | `1 \| 2 \| 3` | - | **(Deprecated)** Auto-detected from `Layout.Column` children count when omitted | +| `title` | `string` | - | **(Deprecated)** Use `` instead | +| `actions` | `React.ReactNode[]` | - | **(Deprecated)** Use `` instead | +| `gap` | `number` | `4` | **(Deprecated)** Use `className` (e.g. `className="gap-6"`) instead | ### Layout.Header Props @@ -228,6 +229,31 @@ If any `Layout.Column` has an `area` prop, all columns switch to area-based widt Columns are rendered in source order — place them in the visual order you want. +## Fill Mode + +By default, `` grows with its content and the AppShell content area scrolls. Set `fill` to instead stretch the layout to the available height and bound its column row, so a child component can scroll **internally** while the page chrome stays pinned: + +```tsx + + + + + + + + + + + + + + +``` + +With `fill`, the `Layout.Header` (title/actions), the `DataTable` toolbar, its column header row, and its footer all remain visible regardless of row count — only the table's rows region scrolls vertically. When the content is short enough to fit, nothing stretches and no scrollbar appears. + +Use `fill` for pages whose main content manages its own scrolling (typically a `DataTable`). Leave it off for pages that should flow and scroll naturally (forms, articles, dashboards). It is intended for single-row layouts; when multiple columns stack vertically on mobile, the content area scrolls as usual. + ## Gap Spacing Use `className` to control the space between columns: diff --git a/examples/vite-app/src/mock-products.ts b/examples/vite-app/src/mock-products.ts index 4e8bc156..6bf7e16b 100644 --- a/examples/vite-app/src/mock-products.ts +++ b/examples/vite-app/src/mock-products.ts @@ -12,7 +12,10 @@ export type Product = { tags: string[]; }; -export const allProducts: Product[] = [ +// Hand-authored products with rich, varied copy. These lead the list so the +// first page always shows realistic, curated data. The remainder is generated +// below to give the table ~200 rows for pagination/sorting/scroll testing. +const curatedProducts: Product[] = [ { id: "p-001", name: "Ergonomic Chair", @@ -290,6 +293,183 @@ export const allProducts: Product[] = [ }, ]; +// --------------------------------------------------------------------------- +// Generated products +// +// The 20 curated rows above are joined with generated rows to give the table +// ~200 products. Generation is fully deterministic (index-derived, no +// randomness) so the data is stable across reloads — deep-linked pages and +// sort/filter state stay reproducible. +// --------------------------------------------------------------------------- + +const CATEGORIES = ["Furniture", "Electronics", "Accessories"] as const; + +const NOUNS_BY_CATEGORY: Record<(typeof CATEGORIES)[number], string[]> = { + Furniture: [ + "Chair", + "Desk", + "Bookshelf", + "Cabinet", + "Table", + "Stool", + "Shelf", + "Locker", + "Workbench", + "Credenza", + "Ottoman", + "Partition", + ], + Electronics: [ + "Keyboard", + "Mouse", + "Monitor", + "Webcam", + "Microphone", + "Headset", + "Speaker", + "Router", + "Hub", + "Adapter", + "Charger", + "Projector", + ], + Accessories: [ + "Lamp", + "Mat", + "Cable", + "Tray", + "Stand", + "Organizer", + "Holder", + "Sleeve", + "Riser", + "Bin", + "Clip", + "Pad", + ], +}; + +const DESCRIPTORS = [ + "Ergonomic", + "Compact", + "Deluxe", + "Pro", + "Premium", + "Wireless", + "Portable", + "Modular", + "Heavy-Duty", + "Smart", + "Eco", + "Ultra", + "Slim", + "Executive", + "Studio", + "Classic", +]; + +const TAG_POOL = [ + "Ergonomic", + "Office", + "Wireless", + "Premium", + "Portable", + "Adjustable", + "Eco-friendly", + "Best Seller", + "Heavy-duty", + "RGB", + "4K", + "USB-C", + "Rechargeable", + "Magnetic", + "Foldable", +]; + +// Weighted toward "Active" so the default view is mostly live products, with a +// steady sprinkling of Draft/Archived for filter testing. +const STATUS_CYCLE: Product["status"][] = [ + "Active", + "Active", + "Active", + "Draft", + "Active", + "Archived", + "Active", + "Draft", + "Active", + "Archived", +]; + +const DESCRIPTION_TEMPLATES: ((name: string, category: string) => string)[] = [ + (name) => `Compact, reliable ${name.toLowerCase()} with a clean finish and a hassle-free setup.`, + (name, category) => + `${name} designed for ${category.toLowerCase()} setups, balancing durability and comfort for everyday professional use.`, + (name, category) => + `Premium ${category.toLowerCase()}-grade ${name.toLowerCase()} featuring reinforced construction, thoughtful cable routing, and a warranty-backed build engineered to hold up across years of daily use.`, +]; + +const pad3 = (n: number) => String(n).padStart(3, "0"); +const pad2 = (n: number) => String(n).padStart(2, "0"); + +// Fixed base so all generated dates are deterministic (2026-01-01T00:00:00Z). +const BASE_PUBLISH_MS = Date.parse("2026-01-01T00:00:00Z"); +const DAY_MS = 24 * 60 * 60 * 1000; + +function generateProducts(count: number, startSeq: number): Product[] { + const products: Product[] = []; + + for (let i = 0; i < count; i++) { + const seq = startSeq + i; + const category = CATEGORIES[i % CATEGORIES.length]; + const nouns = NOUNS_BY_CATEGORY[category]; + const noun = nouns[i % nouns.length]; + const descriptor = DESCRIPTORS[(i * 3) % DESCRIPTORS.length]; + const series = 1 + ((i * 7) % 5); + const name = `${descriptor} ${noun}${series > 1 ? ` S${series}` : ""}`; + + const status = STATUS_CYCLE[i % STATUS_CYCLE.length]; + const price = Number((19.99 + ((i * 53) % 1250)).toFixed(2)); + // Occasionally out of stock, biased toward Draft items. + const stock = status === "Draft" && i % 3 === 0 ? 0 : (i * 37) % 620; + + const publishedMs = BASE_PUBLISH_MS + seq * Math.floor(DAY_MS * 1.5); + const publishedAt = new Date(publishedMs).toISOString(); + const availableOn = new Date(publishedMs + 20 * DAY_MS).toISOString().slice(0, 10); + const restockAt = `${pad2(6 + (i % 12))}:${pad2((i * 5) % 60)}`; + + const tagCount = 1 + (i % 4); + const tags = [ + ...new Set( + Array.from({ length: tagCount }, (_, t) => TAG_POOL[(i * (t + 3)) % TAG_POOL.length]), + ), + ]; + + const describe = DESCRIPTION_TEMPLATES[i % DESCRIPTION_TEMPLATES.length]; + + products.push({ + id: `p-${pad3(seq)}`, + name, + description: describe(name, category), + category, + publishedAt, + availableOn, + restockAt, + price, + stock, + status, + tags, + }); + } + + return products; +} + +export const allProducts: Product[] = [ + ...curatedProducts, + ...generateProducts(180, curatedProducts.length + 1), +]; + // --------------------------------------------------------------------------- // Mock query hook (simulates a real useQuery call) // --------------------------------------------------------------------------- diff --git a/examples/vite-app/src/pages/dashboard/long-content/page.tsx b/examples/vite-app/src/pages/dashboard/long-content/page.tsx new file mode 100644 index 00000000..4eef9c3a --- /dev/null +++ b/examples/vite-app/src/pages/dashboard/long-content/page.tsx @@ -0,0 +1,72 @@ +import { Layout, type AppShellPageProps } from "@tailor-platform/app-shell"; +import { ScrollText } from "lucide-react"; + +// Deliberately long page WITHOUT `fill` on : content grows naturally +// and the AppShell content area scrolls. This exists to confirm that regular +// pages still scroll correctly now that the shell itself is viewport-bounded +// (h-svh + overflow-y-auto on the content area) — compare with /dashboard/ +// products where pins the chrome and scrolls the table rows. +const SECTIONS = [ + "Overview", + "Getting Started", + "Architecture", + "Configuration", + "Data Fetching", + "Routing", + "Authentication", + "Theming", + "Internationalization", + "Testing", + "Deployment", + "Troubleshooting", +]; + +const LongContentPage = () => { + return ( + + + +

+ A long page without fill — the content area + scrolls, not the page body. Scroll down: the sidebar and breadcrumb bar stay put. +

+ {SECTIONS.map((title, i) => ( +
+

+ {i + 1}. {title} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +

+

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat + nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui + officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus + error sit voluptatem accusantium doloremque laudantium. +

+

+ Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia + consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro + quisquam est, qui dolorem ipsum quia dolor sit amet. +

+
+ ))} +

+ 🏁 End of page — if you can read this after scrolling, the content area scrolled + correctly. +

+
+
+ ); +}; + +LongContentPage.appShellPageProps = { + meta: { + title: "Long Content", + icon: , + }, +} satisfies AppShellPageProps; + +export default LongContentPage; diff --git a/examples/vite-app/src/pages/dashboard/products/page.tsx b/examples/vite-app/src/pages/dashboard/products/page.tsx index 1f3fa614..27e5b6b7 100644 --- a/examples/vite-app/src/pages/dashboard/products/page.tsx +++ b/examples/vite-app/src/pages/dashboard/products/page.tsx @@ -92,7 +92,7 @@ const ProductsPage = () => { // synchronously, so the first fetch already reflects the URL. Check the // address bar as you interact. const { variables, control } = useURLCollectionVariables({ - params: { pageSize: 5 }, + params: { pageSize: 25 }, tableMetadata: productMetadata, }); @@ -116,7 +116,9 @@ const ProductsPage = () => { }); return ( - + // `fill` pins the page chrome: the title, table header, and footer stay + // visible while the rows region scrolls internally. +

@@ -132,7 +134,7 @@ const ProductsPage = () => { - + {selectedIds.length > 0 && ( diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index 65c22798..b5032895 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -18,6 +18,7 @@ export type GeneratedRouteParams = { "/": {}; "/dashboard": {}; "/dashboard/document-progress": {}; + "/dashboard/long-content": {}; "/dashboard/orders": {}; "/dashboard/orders/:id": { id: string }; "/dashboard/products": {}; diff --git a/packages/core/skills/app-shell-patterns/references/fundamental/components.md b/packages/core/skills/app-shell-patterns/references/fundamental/components.md index c95b69c6..9a517a74 100644 --- a/packages/core/skills/app-shell-patterns/references/fundamental/components.md +++ b/packages/core/skills/app-shell-patterns/references/fundamental/components.md @@ -47,7 +47,9 @@ For the full upstream API of any component, follow the link to the published ref **Import:** `import { Layout } from '@tailor-platform/app-shell'` **Purpose:** Standard page container with header + 1–N column body. The most-used component — every page wraps content in ``. -**API:** `Layout` (root) + `Layout.Column` + `Layout.Header`. `LayoutProps`: `columns` (number, default auto-detected from children), `gap`, `title`, `actions`, `className`, `style`. +**API:** `Layout` (root) + `Layout.Column` + `Layout.Header`. `LayoutProps`: `fill` (boolean), `columns` (number, default auto-detected from children), `gap`, `title`, `actions`, `className`, `style`. + +**`fill`** — stretches the layout to the available height and bounds the column row so children can scroll internally instead of growing the page. Use on table-first pages (`` + `DataTable`): the title, table toolbar, sticky column headers, and pagination footer stay pinned while only the rows scroll — see **`patterns/list-dense-scan.md`**. Omit on pages that should flow and scroll naturally (forms, dashboards). **Column-count → width rules** (column count is auto-detected from `Layout.Column` children): diff --git a/packages/core/skills/app-shell-patterns/references/fundamental/graphql.md b/packages/core/skills/app-shell-patterns/references/fundamental/graphql.md index 9096953f..c889c42d 100644 --- a/packages/core/skills/app-shell-patterns/references/fundamental/graphql.md +++ b/packages/core/skills/app-shell-patterns/references/fundamental/graphql.md @@ -191,7 +191,7 @@ Every page component: }; ``` -3. Uses `appShellPageProps.guards` for permission gates. See [project-setup.md](project-setup.md). +3. Uses `appShellPageProps.guards` for permission gates and `appShellPageProps.loader` for route loaders. See [project-setup.md](project-setup.md). ## Quick reference diff --git a/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md b/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md index 898516f0..65067276 100644 --- a/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md +++ b/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md @@ -52,13 +52,11 @@ export type Order = { createdAt: string; }; -// Primary status column — filled semantic variants (one per row). -// Secondary status columns (e.g. delivery, billing) use outline-* instead. const statusVariant = { draft: "neutral", - confirmed: "info", - shipped: "warning", - delivered: "success", + confirmed: "outline-info", + shipped: "outline-warning", + delivered: "outline-success", } as const; export const columns: Column[] = [ @@ -69,10 +67,7 @@ export const columns: Column[] = [ render: (row) => {row.status}, }, { - // Numeric columns right-align so digits line up. `type: "money" | "number"` - // auto-right; with a custom `render` set `align` explicitly. label: "Amount", - align: "right", render: (row) => `${row.amount.toLocaleString()}`, }, { label: "Created", accessor: (row) => row.createdAt }, @@ -83,7 +78,7 @@ export const columns: Column[] = [ ```tsx /* pattern: list/dense-scan */ -import { DataTable, useDataTable, Button, Input } from "@tailor-platform/app-shell"; +import { DataTable, Layout, useDataTable, Button, Input } from "@tailor-platform/app-shell"; import type { Order } from "./columns"; import { columns } from "./columns"; import type { DataTableData } from "@tailor-platform/app-shell"; @@ -97,19 +92,46 @@ export default function DenseScanList({ data, onCreateClick }: Props) { const table = useDataTable({ data, columns }); return ( -

-
- - -
- - - -
+ // `fill` pins the page chrome for table-first pages: the title, toolbar, + // column header row, and pagination footer stay visible at every viewport + // height — only the table's rows region scrolls. Omit `fill` on pages + // that should flow and scroll naturally (forms, dashboards, articles). + + + Create Order + , + ]} + /> + + + + + + + + + + + + ); } ``` +## Page Layout & Internal Scrolling + +Table-first pages should pin their chrome and scroll only the rows region. Wrap the page in ``: + +- `fill` stretches the layout to the available height and bounds the column row, so the `DataTable` shrinks to fit instead of growing past the viewport +- The `Layout.Header` (title/actions), `DataTable.Toolbar`, the column header row (sticky), and `DataTable.Footer` (pagination) stay visible at every viewport height — only the rows scroll vertically +- When the current page of rows fits, nothing stretches and no scrollbar appears — short tables render identically with or without `fill` +- Requires no extra styling on the page: the height chain (`AppShell` content area → `Layout fill` → `Layout.Column` → `DataTable.Root`) is wired by the components + +Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, articles) — the AppShell content area scrolls those. + ## Variants - **Toolbar chips only (`DataTable.Filters`)** — best when filters map cleanly to typed column metadata / enum facets @@ -122,8 +144,8 @@ export default function DenseScanList({ data, onCreateClick }: Props) { - Column count: 4-8 recommended - Must include pagination — never render unbounded lists -- Handle every state: `DataTable` renders the loading skeleton and error row; always provide a **labelled empty state** (what the list is + how to add the first record) rather than a bare empty table -- Status Badge colors must use design system tokens (variant prop): the **primary** status column uses **filled** semantic variants; **secondary** status columns (delivery, billing) use **`outline-*`** (see `design-system.md` → Composition & emphasis rules) +- Table-first pages use `` so title/toolbar/header/footer stay pinned and only rows scroll +- Status Badge colors must use design system tokens (variant prop) - Bulk actions toolbar appears only when ≥1 row is selected - Whole row is clickable via `onClickRow`; no per-row "View" / "Open" buttons - Per-row `Menu` (overflow `…`) is reserved for non-navigation actions (Archive, Duplicate, Delete) @@ -131,6 +153,7 @@ export default function DenseScanList({ data, onCreateClick }: Props) { ## Anti-patterns - Building a bespoke table + custom pagination instead of `DataTable` + `useCollectionVariables` +- Hand-rolled `max-height`/`overflow` wrappers around `DataTable` to contain scrolling — use `` instead - Tabs that mutate only local UI state while pagination/filters assume the full server set - Using `
` directly instead of `` for live collections - Client-side filtering on 1000+ records without server-side support diff --git a/packages/core/src/components/badge-list.tsx b/packages/core/src/components/badge-list.tsx index dd6e31ea..cec1b298 100644 --- a/packages/core/src/components/badge-list.tsx +++ b/packages/core/src/components/badge-list.tsx @@ -147,7 +147,10 @@ export function BadgeList({ > +{overflow.length} - + {/* Stacking context on the portal container so the popup renders + above positioned elements like DataTable's sticky header row + (z-index: 10). Same pattern as Menu/Tooltip. */} +
diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index f1d34974..b8c6530a 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -20,6 +20,13 @@ export type { DataTablePaginationProps } from "./pagination"; // Fallback row count when no pageSize is configured (static / uncontrolled tables) const DEFAULT_ROWS = 5; +// Empty/error states reserve a fixed 3 rows' worth of height — enough to +// read as an intentional region, independent of pageSize (so large page +// sizes don't create a huge blank or, when height-constrained, scrollable +// area, and tiny page sizes don't collapse to a thin strip). +const STATUS_ROWS = 3; +const ROW_HEIGHT_PX = 53; + // Resolve the effective horizontal alignment for a column. Numeric `type` // values default to `"right"` so digits line up along their decimal place; // everything else defaults to `"left"`. Explicit `col.align` always wins. @@ -56,27 +63,32 @@ function DataTableLoaderRows>({ hasSelection, hasRowActions, }: DataTableLoaderRowsProps) { + // No fixed row height: each cell's placeholder matches the height of the + // real content it stands in for (text line, badge, icon button), so the + // skeleton rows resolve to exactly the same row height as loaded rows and + // the table doesn't shift when data arrives. return ( <> {Array.from({ length: rowCount }).map((_, rowIndex) => ( {hasSelection && ( - +
)} {columns?.map((col, colIndex) => { const key = col.id ?? col.label ?? String(colIndex); const skeletonWidth = SKELETON_WIDTHS[(rowIndex + colIndex) % SKELETON_WIDTHS.length]; + const isBadge = col.type === "badge"; return ( - +
>({ ); })} {hasRowActions && ( - -
+ + {/* size-9 box = the real icon Button's footprint; the visible + pulse stays 24px to read as an ellipsis placeholder */} +
+
+
)} @@ -100,22 +116,26 @@ function DataTableLoaderRows>({ // ============================================================================= interface DataTableStatusRowProps { - rowCount: number; totalColSpan: number; state: string; children: ReactNode; } /** @internal */ -function DataTableStatusRow({ rowCount, totalColSpan, state, children }: DataTableStatusRowProps) { +function DataTableStatusRow({ totalColSpan, state, children }: DataTableStatusRowProps) { return ( + {/* Fixed 3-row height, independent of pageSize — never pageSize-worth, + which at large page sizes creates a huge blank (or scrollable) + region. The message is sticky (offsets resolve against the + scrollport) so it stays in view even when a height-constrained + container (e.g. inside ) is shorter than this height. */} - {children} +
{children}
); @@ -179,9 +199,16 @@ function DataTableRoot>({ const inner = ( + {/* flex-col + min-h-0 (no flex-1): natural height when content fits, + but able to shrink when the parent chain constrains height (e.g. + ). When shrunk, the Table region scrolls internally + while the Toolbar and Footer (shrink-0) stay visible. */}
{children}
@@ -222,7 +249,18 @@ function DataTableHeaders({ className }: { className?: string }) { const hasSelection = !!toggleRowSelection; return ( - + // Sticky within the DataTable.Table scroll container so column headers + // stay visible while rows scroll beneath. bg-card keeps rows from showing + // through; the inset shadow re-draws the bottom border, which Chrome drops + // from sticky rows under `border-collapse: collapse`. + {hasSelection && ( @@ -361,7 +399,7 @@ function DataTableBody({ className }: { className?: string }) { if (error) { return ( - + {t("errorPrefix")} {error.message} @@ -373,7 +411,7 @@ function DataTableBody({ className }: { className?: string }) { if (!rows || rows.length === 0) { return ( - + {t("noData")} @@ -536,7 +574,15 @@ function RowActionsMenu>({ /** Use `DataTable.Table` instead of calling this directly. */ function DataTableTable({ className }: { className?: string }) { return ( - + // min-h-0 lets the scroll container shrink within DataTable.Root's flex + // column; combined with the container's overflow-auto this is the region + // that scrolls vertically when height is constrained. The sticky header + // (DataTableHeaders) stays pinned to the top of this scrollport. + @@ -554,7 +600,7 @@ function DataTableFooter({ children, className }: { children: ReactNode; classNa
diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 711902cb..bf618084 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -25,7 +25,7 @@ function DataTableToolbar({ children, className }: { children: ReactNode; classN
@@ -491,7 +491,11 @@ function AddFilterPopover({ } /> - + {/* Stacking context on the portal container so the popup renders above + positioned elements like DataTable's sticky header row (z-index: 10) — + the Popup's own z-index is inert (it's position: static; the + Positioner is the positioned element). Same pattern as Menu/Tooltip. */} + } /> - + {/* See AddFilterPopover — stacking context so the popup clears the + sticky table header. */} + { expect(grid.style.getPropertyValue("--layout-cols")).toBe("320px 1fr 280px"); }); + it("fill mode stretches the layout and bounds the column row below the header", () => { + const { container } = render( + + + Content + , + ); + const grid = container.firstElementChild as HTMLElement; + expect(grid.className).toContain("astw:flex-1"); + expect(grid.className).toContain("astw:min-h-0"); + expect(grid.className).toContain("astw:grid-rows-[auto_minmax(0,1fr)]"); + expect(grid.className).toContain("astw:[&>[data-layout-column]]:min-h-0"); + }); + + it("fill mode without a header uses a single bounded row", () => { + const { container } = render( + + Content + , + ); + const grid = container.firstElementChild as HTMLElement; + expect(grid.className).toContain("astw:grid-rows-[minmax(0,1fr)]"); + expect(grid.className).not.toContain("astw:grid-rows-[auto_minmax(0,1fr)]"); + }); + + it("does not apply fill classes by default", () => { + const { container } = render( + + Content + , + ); + const grid = container.firstElementChild as HTMLElement; + expect(grid.className).not.toContain("astw:flex-1"); + expect(grid.className).not.toContain("astw:grid-rows-"); + }); + it("warns when deprecated columns prop doesn't match child count", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); render( diff --git a/packages/core/src/components/layout/Layout.tsx b/packages/core/src/components/layout/Layout.tsx index 29e8a433..ab212012 100644 --- a/packages/core/src/components/layout/Layout.tsx +++ b/packages/core/src/components/layout/Layout.tsx @@ -182,7 +182,16 @@ const POSITION_TEMPLATES: Record = { * * ``` */ -export function Layout({ columns, className, style, gap, title, actions, children }: LayoutProps) { +export function Layout({ + columns, + className, + style, + gap, + title, + actions, + fill, + children, +}: LayoutProps) { const areas: (ColumnArea | undefined)[] = []; let hasHeaderChild = false; @@ -225,15 +234,28 @@ export function Layout({ columns, className, style, gap, title, actions, childre const hasLegacyHeader = !hasHeaderChild && (title || (actions != null && actions.length > 0)); + const hasAnyHeader = hasHeaderChild || hasLegacyHeader; + return (
[data-layout-header]]:col-span-full", effectiveColumnCount === 2 && gridTemplate && "astw:lg:grid-cols-[var(--layout-cols)]", effectiveColumnCount >= 3 && gridTemplate && "astw:xl:grid-cols-[var(--layout-cols)]", + // fill: stretch to the container's height (flex-1 min-h-0 as a flex + // child of the content area) and bound the column row with + // minmax(0, 1fr) — the grid equivalent of min-h-0 — so children like + // DataTable can shrink and scroll internally instead of growing the + // page. Columns also get min-h-0 so the constraint reaches their + // flex-col children. + fill && [ + "astw:flex-1 astw:min-h-0", + hasAnyHeader ? "astw:grid-rows-[auto_minmax(0,1fr)]" : "astw:grid-rows-[minmax(0,1fr)]", + "astw:[&>[data-layout-column]]:min-h-0", + ], className, )} style={ diff --git a/packages/core/src/components/layout/types.ts b/packages/core/src/components/layout/types.ts index 418c9931..97f86662 100644 --- a/packages/core/src/components/layout/types.ts +++ b/packages/core/src/components/layout/types.ts @@ -46,6 +46,20 @@ export interface LayoutProps { * @deprecated Use `` instead. */ actions?: ReactNode[]; + /** + * Fill the available height instead of growing with content. + * + * When set, the layout stretches to the height of its container + * (`flex-1 min-h-0`) and bounds its column row (`minmax(0, 1fr)`), so + * children such as `DataTable` can scroll internally while the + * `Layout.Header` stays pinned. Without it, the layout grows naturally + * and the surrounding content area scrolls. + * + * Intended for single-row (one `Layout.Column` per breakpoint row) pages; + * on stacked mobile layouts with multiple columns the content area + * scrolls as usual. + */ + fill?: boolean; /** Child elements - Layout.Header and/or Layout.Column components */ children: ReactNode; } diff --git a/packages/core/src/components/sidebar.tsx b/packages/core/src/components/sidebar.tsx index 6b9356bc..86d5e6d4 100644 --- a/packages/core/src/components/sidebar.tsx +++ b/packages/core/src/components/sidebar.tsx @@ -153,7 +153,10 @@ function SidebarProvider({ } as React.CSSProperties } className={cn( - "astw:group/sidebar-wrapper astw:has-data-[variant=inset]:bg-sidebar astw:flex astw:min-h-svh astw:w-full astw:overflow-hidden", + // h-svh (not min-h-svh): the shell is viewport-bounded so scrolling + // happens in the content area (or inside components like DataTable), + // never on the document itself. + "astw:group/sidebar-wrapper astw:has-data-[variant=inset]:bg-sidebar astw:flex astw:h-svh astw:w-full astw:overflow-hidden", className, )} {...props} diff --git a/packages/core/src/components/sidebar/sidebar-layout.tsx b/packages/core/src/components/sidebar/sidebar-layout.tsx index 7b2c9704..16040975 100644 --- a/packages/core/src/components/sidebar/sidebar-layout.tsx +++ b/packages/core/src/components/sidebar/sidebar-layout.tsx @@ -3,6 +3,7 @@ import { AppShellOutlet } from "@/components/content"; import { AppearanceSwitcher } from "@/components/appearance-switcher"; import { DefaultSidebar } from "./default-sidebar"; import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb"; +import { cn } from "@/lib/utils"; export type SidebarLayoutProps = { /** @@ -73,7 +74,7 @@ export const SidebarLayout = (props: SidebarLayoutProps) => { collapsible={props.collapsible} className="astw:flex astw:flex-col" > -
+
{props.sidebar ?? }
@@ -87,7 +88,25 @@ export const SidebarLayout = (props: SidebarLayoutProps) => {
-
+ {/* overflow-y-auto: with the shell viewport-bounded (h-svh on the + sidebar wrapper), this is where regular page content scrolls. + Pages that pin their own chrome (e.g. with a + DataTable) size themselves to fit so this never scrolls. + + Full-bleed: break out of SidebarInset's right padding with a + negative margin so the scrollbar sits at the window edge instead + of floating ~32px in, then restore the same padding inside so + content stays aligned with the breadcrumb header. The negative + margin / padding pair mirrors SidebarInset's own responsive + padding (px-4, → px-8 at md when the sidebar is the inset + variant). */} +
{Children ?? }
From 4bae2c1ba40a89203b4070b59d4a263ee2861157 Mon Sep 17 00:00:00 2001 From: interacsean Date: Thu, 2 Jul 2026 16:07:07 +1000 Subject: [PATCH 2/2] feat: scroll-fade under pinned breadcrumb on content-area scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the content area has scrolled, a mask fades its top edge to transparent so content dissolves into the pinned breadcrumb rather than cutting off abruptly. Masking (rather than overlaying a matching colour) reveals the real page backdrop — which is a theme gradient with background-attachment: fixed, so no single colour could match it — making the fade seamless on every theme. Toggled by a lightweight scroll listener (re-renders only when crossing scrollTop 0), so content isn't faded at rest and pages whose content fits show nothing. Co-Authored-By: Claude Opus 4.8 --- .../src/components/sidebar/sidebar-layout.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/core/src/components/sidebar/sidebar-layout.tsx b/packages/core/src/components/sidebar/sidebar-layout.tsx index 16040975..d9735f33 100644 --- a/packages/core/src/components/sidebar/sidebar-layout.tsx +++ b/packages/core/src/components/sidebar/sidebar-layout.tsx @@ -1,3 +1,4 @@ +import { useRef, useState } from "react"; import { SidebarProvider, SidebarInset, SidebarTrigger, useSidebar } from "@/components/sidebar"; import { AppShellOutlet } from "@/components/content"; import { AppearanceSwitcher } from "@/components/appearance-switcher"; @@ -68,6 +69,17 @@ const HidableSidebarTrigger = () => { export const SidebarLayout = (props: SidebarLayoutProps) => { const Children = props.children ? props.children({ Outlet: AppShellOutlet }) : null; + // Toggle a scroll-fade under the pinned breadcrumb once the content area + // has scrolled, so content dissolves into the header instead of cutting off + // abruptly. State only flips when crossing 0, so this doesn't re-render on + // every scroll frame. + const scrollRef = useRef(null); + const [scrolled, setScrolled] = useState(false); + const handleScroll = () => { + const next = (scrollRef.current?.scrollTop ?? 0) > 0; + setScrolled((prev) => (prev === next ? prev : next)); + }; + return ( { margin / padding pair mirrors SidebarInset's own responsive padding (px-4, → px-8 at md when the sidebar is the inset variant). */} + {/* Scroll-fade: once scrolled, a mask fades the top edge of the + content to transparent so it dissolves into the pinned breadcrumb + rather than cutting off. Masking (rather than overlaying a matching + colour) reveals the real page backdrop — which is a theme gradient + with `background-attachment: fixed`, so no single colour could + match it — making the fade seamless on every theme. Only applied + when scrolled, so content isn't faded at rest. */}
{Children ?? }