Skip to content

Commit 405b857

Browse files
interacseanclaude
andcommitted
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 <Layout fill> 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 <noreply@anthropic.com>
1 parent 820b75b commit 405b857

19 files changed

Lines changed: 593 additions & 63 deletions

File tree

.changeset/pinned-table-scroll.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@tailor-platform/app-shell": minor
3+
---
4+
5+
DataTable now scrolls internally: add `fill` prop to `Layout` to pin page chrome and scroll only the table rows.
6+
7+
```tsx
8+
<Layout fill>
9+
<Layout.Header title="Products" />
10+
<Layout.Column>
11+
<DataTable.Root value={table}>
12+
<DataTable.Toolbar>…</DataTable.Toolbar>
13+
<DataTable.Table />
14+
<DataTable.Footer>
15+
<DataTable.Pagination />
16+
</DataTable.Footer>
17+
</DataTable.Root>
18+
</Layout.Column>
19+
</Layout>
20+
```
21+
22+
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.
23+
24+
**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.
25+
26+
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".

catalogue/src/fundamental/components.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ For the full upstream API of any component, follow the link to the published ref
4747
4848
**Import:** `import { Layout } from '@tailor-platform/app-shell'`
4949
**Purpose:** Standard page container with header + 1–N column body. The most-used component — every page wraps content in `<Layout>`.
50-
**API:** `Layout` (root) + `Layout.Column` + `Layout.Header`. `LayoutProps`: `columns` (number, default auto-detected from children), `gap`, `title`, `actions`, `className`, `style`.
50+
**API:** `Layout` (root) + `Layout.Column` + `Layout.Header`. `LayoutProps`: `fill` (boolean), `columns` (number, default auto-detected from children), `gap`, `title`, `actions`, `className`, `style`.
51+
52+
**`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 (`<Layout fill>` + `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).
5153

5254
**Column-count → width rules** (column count is auto-detected from `Layout.Column` children):
5355

catalogue/src/pattern/list/dense-scan/PATTERN.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ dont:
4545

4646
<!-- source: dense-scan.tsx -->
4747

48+
## Page Layout & Internal Scrolling
49+
50+
Table-first pages should pin their chrome and scroll only the rows region. Wrap the page in `<Layout fill>`:
51+
52+
- `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
53+
- 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
54+
- When the current page of rows fits, nothing stretches and no scrollbar appears — short tables render identically with or without `fill`
55+
- Requires no extra styling on the page: the height chain (`AppShell` content area → `Layout fill``Layout.Column``DataTable.Root`) is wired by the components
56+
57+
Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, articles) — the AppShell content area scrolls those.
58+
4859
## Variants
4960

5061
- **Toolbar chips only (`DataTable.Filters`)** — best when filters map cleanly to typed column metadata / enum facets
@@ -57,6 +68,7 @@ dont:
5768

5869
- Column count: 4-8 recommended
5970
- Must include pagination — never render unbounded lists
71+
- Table-first pages use `<Layout fill>` so title/toolbar/header/footer stay pinned and only rows scroll
6072
- Status Badge colors must use design system tokens (variant prop)
6173
- Bulk actions toolbar appears only when ≥1 row is selected
6274
- Whole row is clickable via `onClickRow`; no per-row "View" / "Open" buttons
@@ -65,6 +77,7 @@ dont:
6577
## Anti-patterns
6678

6779
- Building a bespoke table + custom pagination instead of `DataTable` + `useCollectionVariables`
80+
- Hand-rolled `max-height`/`overflow` wrappers around `DataTable` to contain scrolling — use `<Layout fill>` instead
6881
- Tabs that mutate only local UI state while pagination/filters assume the full server set
6982
- Using `<table>` directly instead of `<DataTable>` for live collections
7083
- Client-side filtering on 1000+ records without server-side support
Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* pattern: list/dense-scan */
2-
import { DataTable, useDataTable, Button, Input } from "@tailor-platform/app-shell";
2+
import { DataTable, Layout, useDataTable, Button, Input } from "@tailor-platform/app-shell";
33
import type { Order } from "./columns";
44
import { columns } from "./columns";
55
import type { DataTableData } from "@tailor-platform/app-shell";
@@ -13,14 +13,30 @@ export default function DenseScanList({ data, onCreateClick }: Props) {
1313
const table = useDataTable({ data, columns });
1414

1515
return (
16-
<div className="space-y-4">
17-
<div className="flex items-center justify-between">
18-
<Input placeholder="Search orders..." className="max-w-sm" />
19-
<Button onClick={onCreateClick}>Create Order</Button>
20-
</div>
21-
<DataTable.Root value={table}>
22-
<DataTable.Table />
23-
</DataTable.Root>
24-
</div>
16+
// `fill` pins the page chrome for table-first pages: the title, toolbar,
17+
// column header row, and pagination footer stay visible at every viewport
18+
// height — only the table's rows region scrolls. Omit `fill` on pages
19+
// that should flow and scroll naturally (forms, dashboards, articles).
20+
<Layout fill>
21+
<Layout.Header
22+
title="Orders"
23+
actions={[
24+
<Button key="create" onClick={onCreateClick}>
25+
Create Order
26+
</Button>,
27+
]}
28+
/>
29+
<Layout.Column>
30+
<DataTable.Root value={table}>
31+
<DataTable.Toolbar>
32+
<Input placeholder="Search orders..." className="max-w-sm" />
33+
</DataTable.Toolbar>
34+
<DataTable.Table />
35+
<DataTable.Footer>
36+
<DataTable.Pagination />
37+
</DataTable.Footer>
38+
</DataTable.Root>
39+
</Layout.Column>
40+
</Layout>
2541
);
2642
}

docs/components/layout.md

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,16 @@ The column count is auto-detected from the number of `Layout.Column` children:
3434

3535
### Layout Props
3636

37-
| Prop | Type | Default | Description |
38-
| ----------- | ------------------- | ------------ | ------------------------------------------------------------------------------- |
39-
| `className` | `string` | - | Additional CSS classes for container |
40-
| `style` | `CSSProperties` | - | Inline styles for the layout container |
41-
| `children` | `Layout.Column[]` | **Required** | `Layout.Header` and/or `Layout.Column` components |
42-
| `columns` | `1 \| 2 \| 3` | - | **(Deprecated)** Auto-detected from `Layout.Column` children count when omitted |
43-
| `title` | `string` | - | **(Deprecated)** Use `<Layout.Header title="...">` instead |
44-
| `actions` | `React.ReactNode[]` | - | **(Deprecated)** Use `<Layout.Header actions={[...]}>` instead |
45-
| `gap` | `number` | `4` | **(Deprecated)** Use `className` (e.g. `className="gap-6"`) instead |
37+
| Prop | Type | Default | Description |
38+
| ----------- | ------------------- | ------------ | --------------------------------------------------------------------------------------- |
39+
| `className` | `string` | - | Additional CSS classes for container |
40+
| `style` | `CSSProperties` | - | Inline styles for the layout container |
41+
| `children` | `Layout.Column[]` | **Required** | `Layout.Header` and/or `Layout.Column` components |
42+
| `fill` | `boolean` | `false` | Fill the available height instead of growing with content (see [Fill Mode](#fill-mode)) |
43+
| `columns` | `1 \| 2 \| 3` | - | **(Deprecated)** Auto-detected from `Layout.Column` children count when omitted |
44+
| `title` | `string` | - | **(Deprecated)** Use `<Layout.Header title="...">` instead |
45+
| `actions` | `React.ReactNode[]` | - | **(Deprecated)** Use `<Layout.Header actions={[...]}>` instead |
46+
| `gap` | `number` | `4` | **(Deprecated)** Use `className` (e.g. `className="gap-6"`) instead |
4647

4748
### Layout.Header Props
4849

@@ -228,6 +229,31 @@ If any `Layout.Column` has an `area` prop, all columns switch to area-based widt
228229

229230
Columns are rendered in source order — place them in the visual order you want.
230231

232+
## Fill Mode
233+
234+
By default, `<Layout>` 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:
235+
236+
```tsx
237+
<Layout fill>
238+
<Layout.Header title="Products" />
239+
<Layout.Column>
240+
<DataTable.Root value={table}>
241+
<DataTable.Toolbar>
242+
<DataTable.Filters />
243+
</DataTable.Toolbar>
244+
<DataTable.Table />
245+
<DataTable.Footer>
246+
<DataTable.Pagination />
247+
</DataTable.Footer>
248+
</DataTable.Root>
249+
</Layout.Column>
250+
</Layout>
251+
```
252+
253+
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.
254+
255+
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.
256+
231257
## Gap Spacing
232258

233259
Use `className` to control the space between columns:

examples/vite-app/src/mock-products.ts

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ export type Product = {
1212
tags: string[];
1313
};
1414

15-
export const allProducts: Product[] = [
15+
// Hand-authored products with rich, varied copy. These lead the list so the
16+
// first page always shows realistic, curated data. The remainder is generated
17+
// below to give the table ~200 rows for pagination/sorting/scroll testing.
18+
const curatedProducts: Product[] = [
1619
{
1720
id: "p-001",
1821
name: "Ergonomic Chair",
@@ -290,6 +293,183 @@ export const allProducts: Product[] = [
290293
},
291294
];
292295

296+
// ---------------------------------------------------------------------------
297+
// Generated products
298+
//
299+
// The 20 curated rows above are joined with generated rows to give the table
300+
// ~200 products. Generation is fully deterministic (index-derived, no
301+
// randomness) so the data is stable across reloads — deep-linked pages and
302+
// sort/filter state stay reproducible.
303+
// ---------------------------------------------------------------------------
304+
305+
const CATEGORIES = ["Furniture", "Electronics", "Accessories"] as const;
306+
307+
const NOUNS_BY_CATEGORY: Record<(typeof CATEGORIES)[number], string[]> = {
308+
Furniture: [
309+
"Chair",
310+
"Desk",
311+
"Bookshelf",
312+
"Cabinet",
313+
"Table",
314+
"Stool",
315+
"Shelf",
316+
"Locker",
317+
"Workbench",
318+
"Credenza",
319+
"Ottoman",
320+
"Partition",
321+
],
322+
Electronics: [
323+
"Keyboard",
324+
"Mouse",
325+
"Monitor",
326+
"Webcam",
327+
"Microphone",
328+
"Headset",
329+
"Speaker",
330+
"Router",
331+
"Hub",
332+
"Adapter",
333+
"Charger",
334+
"Projector",
335+
],
336+
Accessories: [
337+
"Lamp",
338+
"Mat",
339+
"Cable",
340+
"Tray",
341+
"Stand",
342+
"Organizer",
343+
"Holder",
344+
"Sleeve",
345+
"Riser",
346+
"Bin",
347+
"Clip",
348+
"Pad",
349+
],
350+
};
351+
352+
const DESCRIPTORS = [
353+
"Ergonomic",
354+
"Compact",
355+
"Deluxe",
356+
"Pro",
357+
"Premium",
358+
"Wireless",
359+
"Portable",
360+
"Modular",
361+
"Heavy-Duty",
362+
"Smart",
363+
"Eco",
364+
"Ultra",
365+
"Slim",
366+
"Executive",
367+
"Studio",
368+
"Classic",
369+
];
370+
371+
const TAG_POOL = [
372+
"Ergonomic",
373+
"Office",
374+
"Wireless",
375+
"Premium",
376+
"Portable",
377+
"Adjustable",
378+
"Eco-friendly",
379+
"Best Seller",
380+
"Heavy-duty",
381+
"RGB",
382+
"4K",
383+
"USB-C",
384+
"Rechargeable",
385+
"Magnetic",
386+
"Foldable",
387+
];
388+
389+
// Weighted toward "Active" so the default view is mostly live products, with a
390+
// steady sprinkling of Draft/Archived for filter testing.
391+
const STATUS_CYCLE: Product["status"][] = [
392+
"Active",
393+
"Active",
394+
"Active",
395+
"Draft",
396+
"Active",
397+
"Archived",
398+
"Active",
399+
"Draft",
400+
"Active",
401+
"Archived",
402+
];
403+
404+
const DESCRIPTION_TEMPLATES: ((name: string, category: string) => string)[] = [
405+
(name) => `Compact, reliable ${name.toLowerCase()} with a clean finish and a hassle-free setup.`,
406+
(name, category) =>
407+
`${name} designed for ${category.toLowerCase()} setups, balancing durability and comfort for everyday professional use.`,
408+
(name, category) =>
409+
`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.`,
410+
];
411+
412+
const pad3 = (n: number) => String(n).padStart(3, "0");
413+
const pad2 = (n: number) => String(n).padStart(2, "0");
414+
415+
// Fixed base so all generated dates are deterministic (2026-01-01T00:00:00Z).
416+
const BASE_PUBLISH_MS = Date.parse("2026-01-01T00:00:00Z");
417+
const DAY_MS = 24 * 60 * 60 * 1000;
418+
419+
function generateProducts(count: number, startSeq: number): Product[] {
420+
const products: Product[] = [];
421+
422+
for (let i = 0; i < count; i++) {
423+
const seq = startSeq + i;
424+
const category = CATEGORIES[i % CATEGORIES.length];
425+
const nouns = NOUNS_BY_CATEGORY[category];
426+
const noun = nouns[i % nouns.length];
427+
const descriptor = DESCRIPTORS[(i * 3) % DESCRIPTORS.length];
428+
const series = 1 + ((i * 7) % 5);
429+
const name = `${descriptor} ${noun}${series > 1 ? ` S${series}` : ""}`;
430+
431+
const status = STATUS_CYCLE[i % STATUS_CYCLE.length];
432+
const price = Number((19.99 + ((i * 53) % 1250)).toFixed(2));
433+
// Occasionally out of stock, biased toward Draft items.
434+
const stock = status === "Draft" && i % 3 === 0 ? 0 : (i * 37) % 620;
435+
436+
const publishedMs = BASE_PUBLISH_MS + seq * Math.floor(DAY_MS * 1.5);
437+
const publishedAt = new Date(publishedMs).toISOString();
438+
const availableOn = new Date(publishedMs + 20 * DAY_MS).toISOString().slice(0, 10);
439+
const restockAt = `${pad2(6 + (i % 12))}:${pad2((i * 5) % 60)}`;
440+
441+
const tagCount = 1 + (i % 4);
442+
const tags = [
443+
...new Set(
444+
Array.from({ length: tagCount }, (_, t) => TAG_POOL[(i * (t + 3)) % TAG_POOL.length]),
445+
),
446+
];
447+
448+
const describe = DESCRIPTION_TEMPLATES[i % DESCRIPTION_TEMPLATES.length];
449+
450+
products.push({
451+
id: `p-${pad3(seq)}`,
452+
name,
453+
description: describe(name, category),
454+
category,
455+
publishedAt,
456+
availableOn,
457+
restockAt,
458+
price,
459+
stock,
460+
status,
461+
tags,
462+
});
463+
}
464+
465+
return products;
466+
}
467+
468+
export const allProducts: Product[] = [
469+
...curatedProducts,
470+
...generateProducts(180, curatedProducts.length + 1),
471+
];
472+
293473
// ---------------------------------------------------------------------------
294474
// Mock query hook (simulates a real useQuery call)
295475
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)