Skip to content
Open
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/brave-pickers-gather.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@tailor-platform/app-shell": minor
---

Add `TreePicker` — a tree-select dialog for picking many items from a hierarchical catalog and committing them in one batch.

Selection is leaf-only: a parent checkbox is derived from its descendants (checked, or indeterminate when partial), and ticking it selects every leaf beneath. Searching keeps any node that matches or has a matching descendant, and auto-expands the ancestors of matches. The tree is keyboard-navigable (`role="tree"` with roving focus, arrow keys to move and expand, Space to toggle, Enter to commit).

```tsx
import { TreePicker } from "@tailor-platform/app-shell";

<TreePicker<CatalogItem>
open={open}
onOpenChange={setOpen}
title="Add items from catalog"
items={catalog}
renderRow={({ node }) => node.data.name}
renderMetric={({ node }) => (node.children ? null : `$${node.data.price.toFixed(2)}`)}
matchesSearch={({ node, query }) => node.data.name.toLowerCase().includes(query.toLowerCase())}
onCommit={(picked) => addToOrder(picked.map((n) => n.data))}
/>;
```

It is generic and standalone: `data` is opaque to the picker, and `onCommit` hands back the picked leaves for the app to use however it needs.

Built-in labels are localized (English and Japanese) through the app locale; any label prop overrides them.
20 changes: 19 additions & 1 deletion docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1429,6 +1429,23 @@
"examples": "87b93974e65d3a07"
}
},
"tree-picker": {
"slug": "tree-picker",
"kind": "code-backed",
"outline": "docs-src/components/tree-picker.docs.outline.md",
"output": "docs/components/tree-picker.md",
"examples": null,
"sources": ["packages/core/src/components/tree-picker/**"],
"claims": [],
"symbols": ["TreePicker", "TreePickerNode", "TreePickerProps"],
"hashes": {
"typeSurface": "d58cf3e3b3559ef4",
"outline": "422bea1a7c11a58d",
"snapshot": "4255a571e84cf5f5",
"outputMd": "010084030ccb6fb5",
"examples": null
}
},
"use-ai-chat": {
"slug": "use-ai-chat",
"kind": "prose",
Expand Down Expand Up @@ -1720,6 +1737,7 @@
"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/tree-picker.md": "010084030ccb6fb5",
"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",
"packages/core/skills/app-shell-patterns/references/api/create-typed-paths.md": "4c3a858d3eb02a2a",
Expand Down Expand Up @@ -1757,6 +1775,6 @@
"packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1",
"packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff",
"packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096",
"packages/core/skills/app-shell-patterns/SKILL.md": "526decbfd2db6555"
"packages/core/skills/app-shell-patterns/SKILL.md": "59a0b9d40318e6ba"
}
}
122 changes: 122 additions & 0 deletions docs-src/components/tree-picker.docs.outline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
kind: code-backed
group: tree-picker
title: TreePicker
description: Tree-select dialog for picking many items from a hierarchical catalog and committing them in one batch
sources:
- packages/core/src/components/tree-picker/**
---

# TreePicker

`TreePicker` is a dialog for picking many items out of a hierarchical catalog — products with variants, nested categories — and handing them back in one batch. Ticking a parent selects every leaf beneath it, and searching auto-expands to the matches.

It is generic and standalone: it knows nothing about line items, or about what your nodes contain. `data` is opaque, handed back only to your render and search callbacks.

## Import

```tsx
import { TreePicker } from "@tailor-platform/app-shell";
```

## Basic Usage

```tsx
type CatalogItem = { sku: string; name: string; price: number };

const CATALOG: TreePickerNode<CatalogItem>[] = [
{
id: "tees",
data: { sku: "TEES", name: "T-Shirts", price: 0 },
children: [
{ id: "TEE-WHT-S", data: { sku: "TEE-WHT-S", name: "Tee, White, Small", price: 18.5 } },
{ id: "TEE-WHT-M", data: { sku: "TEE-WHT-M", name: "Tee, White, Medium", price: 18.5 } },
],
},
];

<TreePicker<CatalogItem>
open={open}
onOpenChange={setOpen}
title="Add items from catalog"
items={CATALOG}
renderRow={({ node }) => node.data.name}
renderMetric={({ node }) => (node.children ? null : `$${node.data.price.toFixed(2)}`)}
rowLabel="Product"
metricLabel="Unit price"
matchesSearch={({ node, query }) =>
`${node.data.sku} ${node.data.name}`.toLowerCase().includes(query.toLowerCase())
}
onCommit={(picked) => addToOrder(picked.map((n) => n.data))}
/>;
```

## Props

| Prop | Type | Default | Description |
| ------------------- | ----------------------------------------- | ---------------------- | ------------------------------------------------------------------------------ |
| `open` | `boolean` | required | Controls dialog visibility. |
| `onOpenChange` | `(open: boolean) => void` | required | Fires on every user-driven open or close, including after a commit. |
| `items` | `ReadonlyArray<TreePickerNode<T>>` | required | The catalog tree. Selection is leaf-only. |
| `renderRow` | `({ node, depth }) => ReactNode` | required | The row's own content. The library draws the checkbox, caret, and indentation. |
| `onCommit` | `(selected: TreePickerNode<T>[]) => void` | required | Receives the selected leaf nodes in tree order. |
| `title` | `ReactNode` | `"Select items"` | Dialog title. Also labels the tree for assistive tech. |
| `renderMetric` | `({ node, depth }) => ReactNode` | – | Optional right-aligned metric column. |
| `rowLabel` | `ReactNode` | `"Item"` | Header above the `renderRow` column. |
| `metricLabel` | `ReactNode` | – | Header above the metric column. |
| `matchesSearch` | `({ node, query }) => boolean` | substring on `data` | Search predicate. **Override whenever `data` is an object.** |
| `filterSlot` | `ReactNode` | – | Slot beside the search input, e.g. a category filter. |
| `ctaLabel` | `(count: number) => ReactNode` | `"Add N items"` | Primary button label. |
| `emptyText` | `ReactNode` | `"No matching items."` | Shown when the filtered tree is empty. |
| `searchPlaceholder` | `string` | `"Search"` | Search input placeholder. |
| `className` | `string` | – | Merged onto the dialog panel. |

The default labels are localized (English and Japanese) through the app's locale, like DataTable's; passing a label prop overrides it.

### TreePickerNode

```tsx
type TreePickerNode<T> = {
id: string;
data: T;
children?: TreePickerNode<T>[];
};
```

`id` must be unique across the whole tree — it doubles as the selection key. A node with no `children` is a leaf.

The default `matchesSearch` stringifies `node.data`, which only works for primitives. With object payloads every node becomes `"[object Object]"` and nothing matches, so pass your own predicate.

## Behavior

**Selection is leaf-only.** Only leaves are ever selected, and only leaves reach `onCommit`. A parent's checkbox is derived from its descendants: checked when all of them are selected, indeterminate when some are. Ticking a parent adds or removes all its leaves at once, and the CTA counts leaves, not rows.

**Search auto-expands.** A node survives the filter if it matches or if any descendant does. Every ancestor of a match is expanded for the duration of the query, so matches are visible without clicking; manually expanded rows stay expanded once the query clears.

**State resets on close.** Query, selection, and expansion are cleared when the dialog closes, so reopening starts fresh.

**Mouse.** Clicking a group row opens or closes it; its checkbox selects everything inside. Clicking an item row ticks or unticks it.

**Keyboard.** The tree is a `role="tree"` with roving focus: Arrow Up/Down move between visible rows, Arrow Right expands a collapsed parent or steps into it, Arrow Left collapses or steps out to the parent, Space toggles the focused row, and Enter commits — except while typing in the search box.

## Loading an async catalog

There is deliberately no async variant: auto-expanding to matches needs the whole tree up front. Fetch once before or on open, and drive `emptyText` from your own loading flag.

```tsx
const { data, isLoading } = useCatalogQuery();

<TreePicker
open={open}
onOpenChange={setOpen}
items={data ?? []}
emptyText={isLoading ? "Loading catalog…" : "No matching items."}
renderRow={({ node }) => node.data.name}
onCommit={handleCommit}
/>;
```

## Related

- [Combobox](./combobox.md) — inline single or multi select for flat option lists
- [Dialog](./dialog.md) — the modal shell this composes
120 changes: 120 additions & 0 deletions docs/components/tree-picker.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions examples/vite-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const AppInner = () => {
<SidebarItem to="/showcase/data-table" />
<SidebarItem to="/showcase/data-table-lab" />
<SidebarItem to="/showcase/data-table-selection" />
<SidebarItem to="/showcase/tree-picker" />
<SidebarItem to="/showcase/alert-tokens" />
<SidebarItem to="/showcase/spinner" />
</SidebarGroup>
Expand Down
Loading
Loading