Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/pinned-table-scroll.md
Original file line number Diff line number Diff line change
@@ -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
<Layout fill>
<Layout.Header title="Products" />
<Layout.Column>
<DataTable.Root value={table}>
<DataTable.Toolbar>…</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Footer>
<DataTable.Pagination />
</DataTable.Footer>
</DataTable.Root>
</Layout.Column>
</Layout>
```

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".
4 changes: 3 additions & 1 deletion catalogue/src/fundamental/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Layout>`.
**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 (`<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).

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

Expand Down
13 changes: 13 additions & 0 deletions catalogue/src/pattern/list/dense-scan/PATTERN.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ dont:

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

## Page Layout & Internal Scrolling

Table-first pages should pin their chrome and scroll only the rows region. Wrap the page in `<Layout fill>`:

- `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
Expand All @@ -57,6 +68,7 @@ dont:

- Column count: 4-8 recommended
- Must include pagination — never render unbounded lists
- Table-first pages use `<Layout fill>` 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
Expand All @@ -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 `<Layout fill>` instead
- Tabs that mutate only local UI state while pagination/filters assume the full server set
- Using `<table>` directly instead of `<DataTable>` for live collections
- Client-side filtering on 1000+ records without server-side support
Expand Down
36 changes: 26 additions & 10 deletions catalogue/src/pattern/list/dense-scan/dense-scan.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,14 +13,30 @@ export default function DenseScanList({ data, onCreateClick }: Props) {
const table = useDataTable({ data, columns });

return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Input placeholder="Search orders..." className="max-w-sm" />
<Button onClick={onCreateClick}>Create Order</Button>
</div>
<DataTable.Root value={table}>
<DataTable.Table />
</DataTable.Root>
</div>
// `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).
<Layout fill>
<Layout.Header
title="Orders"
actions={[
<Button key="create" onClick={onCreateClick}>
Create Order
</Button>,
]}
/>
<Layout.Column>
<DataTable.Root value={table}>
<DataTable.Toolbar>
<Input placeholder="Search orders..." className="max-w-sm" />
</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Footer>
<DataTable.Pagination />
</DataTable.Footer>
</DataTable.Root>
</Layout.Column>
</Layout>
);
}
44 changes: 35 additions & 9 deletions docs/components/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Layout.Header title="...">` instead |
| `actions` | `React.ReactNode[]` | - | **(Deprecated)** Use `<Layout.Header actions={[...]}>` 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 `<Layout.Header title="...">` instead |
| `actions` | `React.ReactNode[]` | - | **(Deprecated)** Use `<Layout.Header actions={[...]}>` instead |
| `gap` | `number` | `4` | **(Deprecated)** Use `className` (e.g. `className="gap-6"`) instead |

### Layout.Header Props

Expand Down Expand Up @@ -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, `<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:

```tsx
<Layout fill>
<Layout.Header title="Products" />
<Layout.Column>
<DataTable.Root value={table}>
<DataTable.Toolbar>
<DataTable.Filters />
</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Footer>
<DataTable.Pagination />
</DataTable.Footer>
</DataTable.Root>
</Layout.Column>
</Layout>
```

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:
Expand Down
182 changes: 181 additions & 1 deletion examples/vite-app/src/mock-products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading