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
@@ -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..d9735f33 100644
--- a/packages/core/src/components/sidebar/sidebar-layout.tsx
+++ b/packages/core/src/components/sidebar/sidebar-layout.tsx
@@ -1,8 +1,10 @@
+import { useRef, useState } from "react";
import { SidebarProvider, SidebarInset, SidebarTrigger, useSidebar } from "@/components/sidebar";
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 = {
/**
@@ -67,13 +69,24 @@ 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 (
-
+ {/* 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). */}
+ {/* 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. */}
+