diff --git a/.changeset/bright-tools-align.md b/.changeset/bright-tools-align.md
new file mode 100644
index 000000000..3d3000ffe
--- /dev/null
+++ b/.changeset/bright-tools-align.md
@@ -0,0 +1,20 @@
+---
+"@tailor-platform/app-shell": minor
+---
+
+Add a full-width `Toolbar` with composable rows, grouped controls, separators, and optional edge-to-edge row layout.
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+```
+
+AppShell Buttons, inputs, selects, comboboxes, and tabs automatically participate in toolbar keyboard navigation.
diff --git a/decisions/generic-toolbar.md b/decisions/generic-toolbar.md
new file mode 100644
index 000000000..d000d3e03
--- /dev/null
+++ b/decisions/generic-toolbar.md
@@ -0,0 +1,61 @@
+# Decision: generic `Toolbar` for action-bar layout
+
+> Status: **Decided — introduce a generic `Toolbar` for layout and compose feature controls inside it. Implemented by PR #559.**
+> Scope: toolbar layout, DataTable composition, and the migration direction for `DataTable.Toolbar`. This does not deprecate or remove `DataTable.Toolbar`.
+
+## Context
+
+`DataTable.Toolbar` combines two concerns:
+
+- layout of a full-width action bar; and
+- DataTable-specific controls and their placement.
+
+That fixed composition does not serve other list presentations or the planned ActionBar use case. A second DataTable-specific layout API would duplicate the composition model: consumers would need to decide when to use the wrapper and when to use a general-purpose toolbar.
+
+A generic toolbar also requires consumers to choose leading/trailing placement explicitly with `justify="between"`. That choice can be missed, producing inconsistent layouts, so the component needs a canonical DataTable scaffold in its documentation and examples.
+
+## Decision
+
+### A single generic layout primitive
+
+Expose `Toolbar` as a layout component with four parts:
+
+- `Toolbar.Root` stacks rows;
+- `Toolbar.Row` is a horizontal action row and supports `justify="start" | "between"`;
+- `Toolbar.Group` keeps related controls together; and
+- `Toolbar.Separator` separates groups visually.
+
+`Toolbar` does not own DataTable controls or any other feature-specific controls. It standardizes the layout details that otherwise drift between screens: padding, gaps, wrapping, borders, and row-level keyboard navigation.
+
+### Feature controls compose directly
+
+`DataTable.Filters` and `DataTable.ColumnSettings` are controls that can be placed in a generic toolbar alongside buttons, inputs, tabs, and controls from other features.
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+When a generic toolbar is a direct child of `DataTable.Root`, DataTable owns the outer frame while the toolbar supplies the divider below its rows.
+
+### Keep the existing DataTable wrapper during migration
+
+`DataTable.Toolbar` remains supported for its existing fixed layout. New custom layouts should use `Toolbar` directly. Deprecating `DataTable.Toolbar` is a separate compatibility and release decision, not part of this change.
+
+## Consequences
+
+- DataTable and non-DataTable screens use the same composition model for action-bar layout.
+- `justify="between"` remains explicit rather than becoming a DataTable-only placement convention; the documentation and example page provide the canonical leading/trailing layout.
+- AppShell buttons, inputs, selects, comboboxes, and tabs participate automatically in a toolbar row's Arrow/Home/End navigation. Composite controls retain their own directional-key behavior.
+- There is no new `DataTable.Toolbar` layout API or named wrapper around the generic toolbar. Add one only if a stable, opinionated DataTable layout proves necessary across consumers.
diff --git a/docs-manifest.json b/docs-manifest.json
index f62141fe7..718997a22 100644
--- a/docs-manifest.json
+++ b/docs-manifest.json
@@ -443,10 +443,10 @@
"withURLCollectionState"
],
"hashes": {
- "typeSurface": "199f1cb9d06e3c15",
- "outline": "2458512deac6c312",
+ "typeSurface": "014a9036a3b9b0af",
+ "outline": "f1bea006bdeb43a4",
"snapshot": null,
- "outputMd": "d4212ff859cfb879",
+ "outputMd": "dd2da884d7550daf",
"examples": null
}
},
@@ -1307,6 +1307,23 @@
"examples": null
}
},
+ "toolbar": {
+ "slug": "toolbar",
+ "kind": "code-backed",
+ "outline": "docs-src/components/toolbar.docs.outline.md",
+ "output": "docs/components/toolbar.md",
+ "examples": null,
+ "sources": ["packages/core/src/components/toolbar/**"],
+ "claims": [],
+ "symbols": ["Toolbar", "ToolbarProps"],
+ "hashes": {
+ "typeSurface": "2d88f0306efcaa77",
+ "outline": "1257a84ccd91d5c3",
+ "snapshot": "3b402cab18147ef9",
+ "outputMd": "91d70b140c7bb747",
+ "examples": null
+ }
+ },
"tooltip": {
"slug": "tooltip",
"kind": "code-backed",
@@ -1588,7 +1605,7 @@
"packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409",
"packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a",
"packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc",
- "packages/core/skills/app-shell-patterns/references/components/data-table.md": "d4212ff859cfb879",
+ "packages/core/skills/app-shell-patterns/references/components/data-table.md": "dd2da884d7550daf",
"packages/core/skills/app-shell-patterns/references/components/date-picker.md": "33902be121ff69db",
"packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be",
"packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571",
@@ -1612,6 +1629,7 @@
"packages/core/skills/app-shell-patterns/references/components/tabs.md": "30bb32e57fc78bd9",
"packages/core/skills/app-shell-patterns/references/components/textarea.md": "c940bc01cd573334",
"packages/core/skills/app-shell-patterns/references/components/timeline.md": "699249c9452485ab",
+ "packages/core/skills/app-shell-patterns/references/components/toolbar.md": "91d70b140c7bb747",
"packages/core/skills/app-shell-patterns/references/components/tooltip.md": "113c95b2995917ed",
"packages/core/skills/app-shell-patterns/references/components/with-guard.md": "d79aaf40cb97073f",
"packages/core/skills/app-shell-patterns/references/api/create-ai-gateway-client.md": "7eaff97521a14ab0",
@@ -1650,6 +1668,6 @@
"packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "8062ea0df9403fbb",
"packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "c3fb2588a0699c26",
"packages/core/skills/app-shell-patterns/references/migrations.md": "799fd5010635b2c2",
- "packages/core/skills/app-shell-patterns/SKILL.md": "165761adda4fbfc0"
+ "packages/core/skills/app-shell-patterns/SKILL.md": "814325f4ea06ffae"
}
}
diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md
index ded8b933f..9c10ea07d 100644
--- a/docs-src/components/data-table.docs.outline.md
+++ b/docs-src/components/data-table.docs.outline.md
@@ -150,14 +150,15 @@ function JournalsPage() {
`DataTable` is a namespace object. All sub-components read state from `DataTable.Root` via context.
-| Sub-component | Description |
-| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. |
-| `DataTable.Table` | Renders the `
` with headers and body. Required. |
-| `DataTable.Toolbar` | Container for toolbar content (e.g. filters). Optional. Pass `columnSettings` to render the built-in "Columns" control (show/hide + reorder + pin) at the top-right. See props below. |
-| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. |
-| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. |
-| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. |
+| Sub-component | Description |
+| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. |
+| `DataTable.Table` | Renders the `
` with headers and body. Required. |
+| `DataTable.Toolbar` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. |
+| `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. |
+| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. |
+| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. |
+| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. |
### `DataTable.Root` Props
@@ -169,18 +170,20 @@ function JournalsPage() {
### `DataTable.Toolbar` Props
-| Prop | Type | Default | Description |
-| ---------------- | ----------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `children` | `ReactNode` | — | Toolbar content (e.g. `DataTable.Filters`), laid out on the left. |
-| `columnSettings` | `boolean` | `false` | Render the built-in "Columns" control (show/hide + reorder + pin) anchored to the top-right. Persists per-user when `useDataTable` has a `tableId`. |
-| `className` | `string` | — | Additional CSS class for the toolbar container. |
+| Prop | Type | Default | Description |
+| ---------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| `children` | `ReactNode` | — | Toolbar content, stacked on the left. |
+| `columnSettings` | `boolean` | `false` | Render the built-in Columns control at the top-right. Use [`Toolbar`](./toolbar.md) with `DataTable.ColumnSettings` for new custom layouts. |
+| `className` | `string` | — | Additional CSS class for the toolbar container. |
+
+For new layouts, use the generic [`Toolbar`](./toolbar.md).
### `DataTable.Filters` Props
| Prop | Type | Default | Description |
| ------------- | --------------------------- | ------- | ---------------------------------------------------------------------------------------------- |
| `slot` | `"all" \| "chips" \| "add"` | `"all"` | Which part to render (see below). |
-| `addIconOnly` | `boolean` | `false` | Render the **Add filter** trigger as an icon-only button (the label becomes its `aria-label`). |
+| `addIconOnly` | `boolean` | `true` | Render the **Add filter** trigger as an icon-only button (the label becomes its `aria-label`). |
| `className` | `string` | — | Additional CSS class for the filters container. |
By default `DataTable.Filters` renders the active filter chips plus the **Add filter** trigger together. The `slot` prop lets you split them across a custom toolbar layout:
@@ -191,13 +194,21 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi
```tsx
// Add filter in a header row (with tabs, etc.); chips on the row below.
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
```
### `DataTable.Pagination` Props
@@ -222,7 +233,7 @@ When pagination changes page or page size, `DataTable.Table` resets its own scro
## Column pinning, visibility & ordering
- **Pin** a column with `pin: "left" | "right"`. Pinned columns stay visible during horizontal scroll; the selection and expand columns auto-pin left and the row-actions column auto-pins right. A subtle shadow appears at the frozen edge once the table is scrolled under it. Sticky offsets are measured from the rendered layout, so a `width` isn't required — but setting `width` on pinned columns is recommended so their size stays stable as content changes.
-- **Column settings.** Pass `columnSettings` to `DataTable.Toolbar` to render a built-in "Columns" control — a popover to show/hide columns, reorder them (drag), and change pinning by dragging a column between the **Fixed left**, **Scrollable**, and **Fixed right** zones. It's a toolbar prop (not a composed sub-component) because the control always sits in the same top-right position.
+- **Column settings.** `DataTable.ColumnSettings` renders a popover to show/hide columns, reorder them (drag), and change pinning by dragging a column between the **Fixed left**, **Scrollable**, and **Fixed right** zones. Place it in a generic [`Toolbar`](./toolbar.md), or pass `columnSettings` to `DataTable.Toolbar` for its fixed top-right placement.
- **Persistence.** Pass a stable, **unique** `tableId` to persist each user's column layout (visibility, order, pinning) to `localStorage` (key `as:data-table:v1:`). This is a per-user preference — it is deliberately **not** stored in the URL like filters/sort/pagination, so it survives reloads and isn't reset by shared/filtered links. Omit `tableId` for in-memory-only layout (state simply isn't persisted). Two tables mounted with the same `tableId` share one storage key and overwrite each other — use a unique id per table (e.g. `:`); a dev-mode warning fires on duplicates.
```tsx
@@ -233,7 +244,16 @@ const table = useDataTable({
});
-
+
+
+
+
+
+
+
+
+
+ ;
```
@@ -404,7 +424,7 @@ A column definition passed to `useDataTable`. `Column` is a discriminated
| `render` | `(row: TRow) => ReactNode` | Renders the cell content. Presentation only. Optional — overrides the built-in `type` renderer when set. Built-in behaviors that need the raw value (truncate tooltip, cell copy/filter context-menu actions) still resolve it from `accessor` first, then `row[col.id]`, and only fall back to the return value of `render(row)` when it is a primitive (`string`, `number`, `boolean`, or `bigint`). |
| `id` | `string` | Stable identifier for column visibility, persisted layout state, raw-value fallback (`row[col.id]`), and the React key. Falls back to `label` when omitted. Set this explicitly when `label` is absent, not unique, or when a custom-rendered column should still participate in built-in cell behaviors that need a raw value. |
| `width` | `number` | Fixed column width in pixels. Optional. |
-| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's `columnSettings` control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection and expand columns auto-pin left and the row-actions column auto-pins right. |
+| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's column-settings control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection and expand columns auto-pin left and the row-actions column auto-pin right. |
| `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. |
| `truncate` | `boolean` | Truncate overflowing text with an ellipsis. Wires up an app-shell `` automatically when the resolved raw cell value is a string or number (`accessor` first, then `row[col.id]`). With `inferColumns`, no explicit `accessor` is needed because `id` is pinned to the field name. Requires another column to anchor the row width (`width` on a neighbor, or a fixed-size column like selection / row actions). |
| `accessor` | _(narrowed per `type`)_ | Extracts the raw value. This is the primary source of truth for built-in behaviors that need a value independent of presentation (typed rendering, truncate tooltip, cell copy/filter context-menu actions). The return type is narrowed per `type` branch — returning an array is a compile error on all typed columns except `badge`, and returning a plain object is a compile error on all typed columns. Untyped columns (`type` omitted) retain `unknown`. `null` and `undefined` are always allowed. |
diff --git a/docs-src/components/toolbar.docs.outline.md b/docs-src/components/toolbar.docs.outline.md
new file mode 100644
index 000000000..2676be61a
--- /dev/null
+++ b/docs-src/components/toolbar.docs.outline.md
@@ -0,0 +1,140 @@
+---
+kind: code-backed
+group: toolbar
+title: Toolbar
+description: Full-width action bars with composable rows, grouped controls, and keyboard navigation
+sources:
+ - packages/core/src/components/toolbar/**
+---
+
+# Toolbar
+
+`Toolbar` is a full-width action bar. It is independent of `DataTable`: place any AppShell or application control in it, including buttons, search fields, selects, comboboxes, and tabs.
+
+```tsx
+import { Toolbar } from "@tailor-platform/app-shell";
+
+
+
+
+
+
+
+
+
+
+;
+```
+
+## Structure
+
+`Toolbar.Root` stacks rows. `Toolbar.Row` is one horizontal, keyboard-navigable action row. Use `Toolbar.Group` to keep related controls together and `Toolbar.Separator` to divide groups.
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Row alignment
+
+Use `justify="between"` when a row has one leading and one trailing group. The row places those outer groups at opposite edges. The default, `"start"`, keeps every group at the leading edge.
+
+```tsx
+
+ {/* search and filters */}
+ {/* export and create actions */}
+
+```
+
+Keep controls that belong to the leading region in the same group. `justify="between"` distributes **direct** groups, so a separator should also stay inside that group.
+
+### Sizing fields
+
+`Input` keeps its normal `w-full` behavior inside a toolbar. Give a field an explicit layout box when it shares a group with other controls.
+
+```tsx
+
+
+
+
+
+
+
+```
+
+## Keyboard navigation
+
+Every row has `role="toolbar"` and supports `ArrowLeft`, `ArrowRight`, `Home`, and `End` for registered AppShell controls. Disabled controls are skipped and focus loops at either end.
+
+AppShell `Button`, `Input`, `Select`, `Combobox`, and `Tabs.Tab` register automatically when rendered inside a row. Composite controls retain their own directional keys: arrows move the caret in an input, move between tabs, or navigate an open select/combobox. Use `Tab` to leave a composite control.
+
+Each row should have an accessible name via `aria-label` or `aria-labelledby`.
+
+## DataTable example
+
+`DataTable.Filters` and `DataTable.ColumnSettings` are ordinary toolbar controls. They can be placed wherever the layout requires.
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+When a generic toolbar is a direct child of `DataTable.Root`, DataTable retains its outer top corners and turns the toolbar border into only the divider beneath the toolbar.
+
+## API
+
+### `Toolbar.Root`
+
+| Prop | Type | Default | Description |
+| ----------- | ----------- | ------- | ------------------------------------------------ |
+| `children` | `ReactNode` | — | One or more `Toolbar.Row` elements. |
+| `className` | `string` | — | Additional classes for the full-width container. |
+
+### `Toolbar.Row`
+
+| Prop | Type | Default | Description |
+| -------------------------------- | ---------------------- | --------- | ------------------------------------------------------- |
+| `children` | `ReactNode` | — | Groups and separators in this horizontal row. |
+| `justify` | `"start" \| "between"` | `"start"` | Align direct groups at the start, or at opposite edges. |
+| `aria-label` / `aria-labelledby` | `string` | — | Accessible name for this toolbar row. |
+| `className` | `string` | — | Additional classes for the row. |
+
+### `Toolbar.Group`
+
+| Prop | Type | Default | Description |
+| ----------- | ----------- | ------- | ----------------------------------------------------------- |
+| `children` | `ReactNode` | — | Related controls arranged horizontally and allowed to wrap. |
+| `className` | `string` | — | Additional classes for the group. |
+
+### `Toolbar.Separator`
+
+A vertical, accessible separator between related control groups. It accepts `className` for visual customization.
diff --git a/docs/components/data-table.md b/docs/components/data-table.md
index de8fc8750..6d5b14f72 100644
--- a/docs/components/data-table.md
+++ b/docs/components/data-table.md
@@ -144,14 +144,15 @@ function JournalsPage() {
`DataTable` is a namespace object. All sub-components read state from `DataTable.Root` via context.
-| Sub-component | Description |
-| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. |
-| `DataTable.Table` | Renders the `
` with headers and body. Required. |
-| `DataTable.Toolbar` | Container for toolbar content (e.g. filters). Optional. Pass `columnSettings` to render the built-in "Columns" control (show/hide + reorder + pin) at the top-right. See props below. |
-| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. |
-| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. |
-| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. |
+| Sub-component | Description |
+| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. |
+| `DataTable.Table` | Renders the `
` with headers and body. Required. |
+| `DataTable.Toolbar` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. |
+| `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. |
+| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. |
+| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. |
+| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. |
### `DataTable.Root` Props
@@ -163,18 +164,20 @@ function JournalsPage() {
### `DataTable.Toolbar` Props
-| Prop | Type | Default | Description |
-| ---------------- | ----------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `children` | `ReactNode` | — | Toolbar content (e.g. `DataTable.Filters`), laid out on the left. |
-| `columnSettings` | `boolean` | `false` | Render the built-in "Columns" control (show/hide + reorder + pin) anchored to the top-right. Persists per-user when `useDataTable` has a `tableId`. |
-| `className` | `string` | — | Additional CSS class for the toolbar container. |
+| Prop | Type | Default | Description |
+| ---------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| `children` | `ReactNode` | — | Toolbar content, stacked on the left. |
+| `columnSettings` | `boolean` | `false` | Render the built-in Columns control at the top-right. Use [`Toolbar`](./toolbar.md) with `DataTable.ColumnSettings` for new custom layouts. |
+| `className` | `string` | — | Additional CSS class for the toolbar container. |
+
+For new layouts, use the generic [`Toolbar`](./toolbar.md).
### `DataTable.Filters` Props
| Prop | Type | Default | Description |
| ------------- | --------------------------- | ------- | ---------------------------------------------------------------------------------------------- |
| `slot` | `"all" \| "chips" \| "add"` | `"all"` | Which part to render (see below). |
-| `addIconOnly` | `boolean` | `false` | Render the **Add filter** trigger as an icon-only button (the label becomes its `aria-label`). |
+| `addIconOnly` | `boolean` | `true` | Render the **Add filter** trigger as an icon-only button (the label becomes its `aria-label`). |
| `className` | `string` | — | Additional CSS class for the filters container. |
By default `DataTable.Filters` renders the active filter chips plus the **Add filter** trigger together. The `slot` prop lets you split them across a custom toolbar layout:
@@ -185,13 +188,21 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi
```tsx
// Add filter in a header row (with tabs, etc.); chips on the row below.
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
```
### `DataTable.Pagination` Props
@@ -216,7 +227,7 @@ When pagination changes page or page size, `DataTable.Table` resets its own scro
## Column pinning, visibility & ordering
- **Pin** a column with `pin: "left" | "right"`. Pinned columns stay visible during horizontal scroll; the selection and expand columns auto-pin left and the row-actions column auto-pins right. A subtle shadow appears at the frozen edge once the table is scrolled under it. Sticky offsets are measured from the rendered layout, so a `width` isn't required — but setting `width` on pinned columns is recommended so their size stays stable as content changes.
-- **Column settings.** Pass `columnSettings` to `DataTable.Toolbar` to render a built-in "Columns" control — a popover to show/hide columns, reorder them (drag), and change pinning by dragging a column between the **Fixed left**, **Scrollable**, and **Fixed right** zones. It's a toolbar prop (not a composed sub-component) because the control always sits in the same top-right position.
+- **Column settings.** `DataTable.ColumnSettings` renders a popover to show/hide columns, reorder them (drag), and change pinning by dragging a column between the **Fixed left**, **Scrollable**, and **Fixed right** zones. Place it in a generic [`Toolbar`](./toolbar.md), or pass `columnSettings` to `DataTable.Toolbar` for its fixed top-right placement.
- **Persistence.** Pass a stable, **unique** `tableId` to persist each user's column layout (visibility, order, pinning) to `localStorage` (key `as:data-table:v1:`). This is a per-user preference — it is deliberately **not** stored in the URL like filters/sort/pagination, so it survives reloads and isn't reset by shared/filtered links. Omit `tableId` for in-memory-only layout (state simply isn't persisted). Two tables mounted with the same `tableId` share one storage key and overwrite each other — use a unique id per table (e.g. `:`); a dev-mode warning fires on duplicates.
```tsx
@@ -227,7 +238,16 @@ const table = useDataTable({
});
-
+
+
+
+
+
+
+
+
+
+ ;
```
@@ -398,7 +418,7 @@ A column definition passed to `useDataTable`. `Column` is a discriminated
| `render` | `(row: TRow) => ReactNode` | Renders the cell content. Presentation only. Optional — overrides the built-in `type` renderer when set. Built-in behaviors that need the raw value (truncate tooltip, cell copy/filter context-menu actions) still resolve it from `accessor` first, then `row[col.id]`, and only fall back to the return value of `render(row)` when it is a primitive (`string`, `number`, `boolean`, or `bigint`). |
| `id` | `string` | Stable identifier for column visibility, persisted layout state, raw-value fallback (`row[col.id]`), and the React key. Falls back to `label` when omitted. Set this explicitly when `label` is absent, not unique, or when a custom-rendered column should still participate in built-in cell behaviors that need a raw value. |
| `width` | `number` | Fixed column width in pixels. Optional. |
-| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's `columnSettings` control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection and expand columns auto-pin left and the row-actions column auto-pins right. |
+| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's column-settings control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection and expand columns auto-pin left and the row-actions column auto-pin right. |
| `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. |
| `truncate` | `boolean` | Truncate overflowing text with an ellipsis. Wires up an app-shell `` automatically when the resolved raw cell value is a string or number (`accessor` first, then `row[col.id]`). With `inferColumns`, no explicit `accessor` is needed because `id` is pinned to the field name. Requires another column to anchor the row width (`width` on a neighbor, or a fixed-size column like selection / row actions). |
| `accessor` | _(narrowed per `type`)_ | Extracts the raw value. This is the primary source of truth for built-in behaviors that need a value independent of presentation (typed rendering, truncate tooltip, cell copy/filter context-menu actions). The return type is narrowed per `type` branch — returning an array is a compile error on all typed columns except `badge`, and returning a plain object is a compile error on all typed columns. Untyped columns (`type` omitted) retain `unknown`. `null` and `undefined` are always allowed. |
diff --git a/docs/components/toolbar.md b/docs/components/toolbar.md
new file mode 100644
index 000000000..24c2eff83
--- /dev/null
+++ b/docs/components/toolbar.md
@@ -0,0 +1,138 @@
+---
+title: Toolbar
+description: Full-width action bars with composable rows, grouped controls, and keyboard navigation
+---
+
+
+
+# Toolbar
+
+`Toolbar` is a full-width action bar. It is independent of `DataTable`: place any AppShell or application control in it, including buttons, search fields, selects, comboboxes, and tabs.
+
+```tsx
+import { Toolbar } from "@tailor-platform/app-shell";
+
+
+
+
+
+
+
+
+
+
+;
+```
+
+## Structure
+
+`Toolbar.Root` stacks rows. `Toolbar.Row` is one horizontal, keyboard-navigable action row. Use `Toolbar.Group` to keep related controls together and `Toolbar.Separator` to divide groups.
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Row alignment
+
+Use `justify="between"` when a row has one leading and one trailing group. The row places those outer groups at opposite edges. The default, `"start"`, keeps every group at the leading edge.
+
+```tsx
+
+ {/* search and filters */}
+ {/* export and create actions */}
+
+```
+
+Keep controls that belong to the leading region in the same group. `justify="between"` distributes **direct** groups, so a separator should also stay inside that group.
+
+### Sizing fields
+
+`Input` keeps its normal `w-full` behavior inside a toolbar. Give a field an explicit layout box when it shares a group with other controls.
+
+```tsx
+
+
+
+
+
+
+
+```
+
+## Keyboard navigation
+
+Every row has `role="toolbar"` and supports `ArrowLeft`, `ArrowRight`, `Home`, and `End` for registered AppShell controls. Disabled controls are skipped and focus loops at either end.
+
+AppShell `Button`, `Input`, `Select`, `Combobox`, and `Tabs.Tab` register automatically when rendered inside a row. Composite controls retain their own directional keys: arrows move the caret in an input, move between tabs, or navigate an open select/combobox. Use `Tab` to leave a composite control.
+
+Each row should have an accessible name via `aria-label` or `aria-labelledby`.
+
+## DataTable example
+
+`DataTable.Filters` and `DataTable.ColumnSettings` are ordinary toolbar controls. They can be placed wherever the layout requires.
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+When a generic toolbar is a direct child of `DataTable.Root`, DataTable retains its outer top corners and turns the toolbar border into only the divider beneath the toolbar.
+
+## API
+
+### `Toolbar.Root`
+
+| Prop | Type | Default | Description |
+| ----------- | ----------- | ------- | ------------------------------------------------ |
+| `children` | `ReactNode` | — | One or more `Toolbar.Row` elements. |
+| `className` | `string` | — | Additional classes for the full-width container. |
+
+### `Toolbar.Row`
+
+| Prop | Type | Default | Description |
+| -------------------------------- | ---------------------- | --------- | ------------------------------------------------------- |
+| `children` | `ReactNode` | — | Groups and separators in this horizontal row. |
+| `justify` | `"start" \| "between"` | `"start"` | Align direct groups at the start, or at opposite edges. |
+| `aria-label` / `aria-labelledby` | `string` | — | Accessible name for this toolbar row. |
+| `className` | `string` | — | Additional classes for the row. |
+
+### `Toolbar.Group`
+
+| Prop | Type | Default | Description |
+| ----------- | ----------- | ------- | ----------------------------------------------------------- |
+| `children` | `ReactNode` | — | Related controls arranged horizontally and allowed to wrap. |
+| `className` | `string` | — | Additional classes for the group. |
+
+### `Toolbar.Separator`
+
+A vertical, accessible separator between related control groups. It accepts `className` for visual customization.
diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx
index c778b6a64..cc372536d 100644
--- a/examples/vite-app/src/App.tsx
+++ b/examples/vite-app/src/App.tsx
@@ -74,6 +74,7 @@ const AppInner = () => {
+
diff --git a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx
index e20955552..254079d41 100644
--- a/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx
+++ b/examples/vite-app/src/pages/showcase/data-table-lab/page.tsx
@@ -3,6 +3,7 @@ import {
Badge,
Button,
DataTable,
+ Toolbar,
useDataTable,
useCollectionVariables,
createColumnHelper,
@@ -552,16 +553,24 @@ const DataTableLabPage = () => {
description={
<>
Add filter (left) and the Columns control (right)
- share one toolbar row. Open Columns to show/hide, drag to reorder,
- and drag between zones to pin left/right. The Invoice column is pinned left
- and the actions column is pinned right by default. Changes persist across reloads.
+ share one generic Toolbar.Row. Open Columns to
+ show/hide, drag to reorder, and drag between zones to pin left/right. The{" "}
+ Invoice column is pinned left and the actions column is pinned right by
+ default. Changes persist across reloads.
>
}
>
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/vite-app/src/pages/showcase/data-table/page.tsx b/examples/vite-app/src/pages/showcase/data-table/page.tsx
index 3f38e0a74..6c50a2a1e 100644
--- a/examples/vite-app/src/pages/showcase/data-table/page.tsx
+++ b/examples/vite-app/src/pages/showcase/data-table/page.tsx
@@ -3,6 +3,8 @@ import {
Layout,
Badge,
DataTable,
+ Tabs,
+ Toolbar,
useDataTable,
useCollectionVariables,
createColumnHelper,
@@ -287,22 +289,15 @@ function StatusTabs({ control }: { control: CollectionControl }) {
const select = (key: string) =>
key === "all" ? control.removeFilter("status") : control.addFilter("status", "in", [key]);
return (
-
+ A generic control bar built from rows, groups, and separators. Focus a button and use the
+ arrow keys, Home, or End to move within its row. Text inputs keep their native arrow-key
+ behavior.
+