@tailor-platform/app-shell 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/app-shell-core.css +2 -2
  2. package/dist/app-shell.d.ts +807 -130
  3. package/dist/app-shell.js +12419 -10930
  4. package/dist/{appshell-DQTz-QwK.js → appshell-B4QkQvXF.js} +377 -360
  5. package/dist/fonts/inter.css +7 -7
  6. package/dist/fonts/noto-sans-jp.css +116 -116
  7. package/dist/testing.d.ts +119 -89
  8. package/dist/testing.js +1 -1
  9. package/dist/vite-plugin.d.ts +119 -89
  10. package/package.json +10 -8
  11. package/skills/app-shell-patterns/SKILL.md +4 -3
  12. package/skills/app-shell-patterns/references/api/create-ai-gateway-client.md +32 -2
  13. package/skills/app-shell-patterns/references/api/define-module.md +5 -5
  14. package/skills/app-shell-patterns/references/api/router/use-params.md +2 -2
  15. package/skills/app-shell-patterns/references/api/use-ai-chat.md +46 -8
  16. package/skills/app-shell-patterns/references/api/use-app-shell-config.md +4 -4
  17. package/skills/app-shell-patterns/references/api/use-app-shell-data.md +5 -5
  18. package/skills/app-shell-patterns/references/api/use-app-shell.md +2 -2
  19. package/skills/app-shell-patterns/references/api/use-auth.md +3 -3
  20. package/skills/app-shell-patterns/references/api/use-route-error.md +12 -12
  21. package/skills/app-shell-patterns/references/components/action-panel.md +4 -2
  22. package/skills/app-shell-patterns/references/components/activity-card.md +3 -3
  23. package/skills/app-shell-patterns/references/components/ai-chat.md +37 -3
  24. package/skills/app-shell-patterns/references/components/app-shell.md +6 -5
  25. package/skills/app-shell-patterns/references/components/card.md +2 -2
  26. package/skills/app-shell-patterns/references/components/data-table.md +87 -31
  27. package/skills/app-shell-patterns/references/components/date-picker.md +2 -2
  28. package/skills/app-shell-patterns/references/components/default-sidebar.md +2 -2
  29. package/skills/app-shell-patterns/references/components/dialog.md +8 -0
  30. package/skills/app-shell-patterns/references/components/filter-rail.md +10 -3
  31. package/skills/app-shell-patterns/references/components/form.md +5 -5
  32. package/skills/app-shell-patterns/references/components/global-header-layout.md +31 -8
  33. package/skills/app-shell-patterns/references/components/layout.md +24 -17
  34. package/skills/app-shell-patterns/references/components/select.md +8 -0
  35. package/skills/app-shell-patterns/references/components/sidebar-group.md +9 -7
  36. package/skills/app-shell-patterns/references/components/sidebar-layout.md +74 -11
  37. package/skills/app-shell-patterns/references/components/sidebar-section.md +8 -8
  38. package/skills/app-shell-patterns/references/components/tabs.md +32 -2
  39. package/skills/app-shell-patterns/references/components/tree.md +342 -0
  40. package/skills/app-shell-patterns/references/components/with-guard.md +5 -5
  41. package/skills/app-shell-patterns/references/concepts/authentication.md +1 -1
  42. package/skills/app-shell-patterns/references/concepts/routing-navigation.md +3 -3
  43. package/skills/app-shell-patterns/references/concepts/styling-theming.md +13 -13
  44. package/skills/app-shell-patterns/references/migrations.md +20 -0
  45. package/skills/app-shell-patterns/references/pages/document-detail.md +8 -12
  46. package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +1 -1
@@ -48,12 +48,13 @@ This gives you:
48
48
 
49
49
  ### Common props
50
50
 
51
- | Prop | Type | Default | Description |
52
- | ------------- | ----------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
53
- | `sidebar` | `React.ReactNode` | `<SidebarLayout.DefaultSidebar />` | Replaces the entire sidebar region. |
54
- | `defaultOpen` | `boolean` | `true` | Whether the sidebar starts expanded on desktop. |
55
- | `collapsible` | `boolean` | `true` | Whether the sidebar can be collapsed. When `false`, toggle controls are hidden and `defaultOpen` is ignored. |
56
- | `topBar` | `React.ReactNode` | - | Full-width bar above both the sidebar and the content region. |
51
+ | Prop | Type | Default | Description |
52
+ | ------------------ | ------------------------------------------------------------ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `sidebar` | `React.ReactNode` | `<SidebarLayout.DefaultSidebar />` | Replaces the entire sidebar region. |
54
+ | `defaultOpen` | `boolean` | `true` | Whether the sidebar starts expanded on desktop. Ignored in `"fixed"` and `"resize"` modes. |
55
+ | `sidebarBehaviour` | `"fixed" \| "collapse" \| "resize" \| "collapse-and-resize"` | `"collapse-and-resize"` | Selects the permitted desktop operations. Mobile/tablet drawer access is independent of this setting. |
56
+ | `collapsible` | `boolean` | - | Deprecated compatibility prop: explicit `false` maps to `"fixed"` and explicit `true` to `"collapse"`. Cannot be combined with `sidebarBehaviour`. |
57
+ | `topBar` | `React.ReactNode` | - | Full-width bar above both the sidebar and the content region. |
57
58
 
58
59
  ### Default content mode
59
60
 
@@ -74,6 +75,61 @@ Use this when you need to compose your own columns beside the sidebar.
74
75
 
75
76
  `body` is mutually exclusive with `header` and `children`, because it replaces the region those props normally describe.
76
77
 
78
+ ## Resizing the sidebar
79
+
80
+ `sidebarBehaviour` selects the desktop operations. It defaults to `"collapse-and-resize"`:
81
+
82
+ | Mode | Collapse | Resize |
83
+ | ----------------------- | -------- | ------ |
84
+ | `"fixed"` | No | No |
85
+ | `"collapse"` | Yes | No |
86
+ | `"resize"` | No | Yes |
87
+ | `"collapse-and-resize"` | Yes | Yes |
88
+
89
+ ```tsx
90
+ <SidebarLayout />; // Both operations are enabled by default
91
+ <SidebarLayout sidebarBehaviour="fixed" />;
92
+ <SidebarLayout sidebarBehaviour="collapse" />;
93
+ <SidebarLayout sidebarBehaviour="resize" />;
94
+ <SidebarLayout sidebarBehaviour="collapse-and-resize" />;
95
+ ```
96
+
97
+ `"fixed"` and `"resize"` always keep the desktop sidebar expanded, ignore `defaultOpen`, and make `useAppShellSidebar().setOpen()` inert. They hide desktop collapse controls and do not intercept the desktop toggle shortcut. `"collapse"` has toggle controls but no resize handle.
98
+
99
+ All modes keep the layout's normal mobile/tablet drawer and icon-rail overlay presentation. The trigger, `toggle()`, and shortcut can still open the full menu when desktop collapsing is disabled. These responsive visibility states are independent of desktop expansion.
100
+
101
+ ### Deprecated `collapsible`
102
+
103
+ The existing boolean remains supported but is deprecated. Explicit `false` maps to `"fixed"`; explicit `true` maps to `"collapse"`. Omitting both props uses the new `"collapse-and-resize"` default. TypeScript rejects specifying both props together.
104
+
105
+ ```tsx
106
+ // Before
107
+ <SidebarLayout collapsible={false} />;
108
+
109
+ // After
110
+ <SidebarLayout sidebarBehaviour="fixed" />;
111
+ ```
112
+
113
+ This intentionally changes the responsive presentation of legacy `collapsible={false}`: it no longer forces a fixed sidebar on mobile/tablet. It keeps desktop fixed while using the normal responsive drawer/overlay. A completely custom `sidebar` still owns its own presentation and sizing.
114
+
115
+ ### Resize interaction
116
+
117
+ The initial width is 256px. In `"resize"` and `"collapse-and-resize"` modes, the whole outer edge of the expanded desktop sidebar is a resize handle. Hovering it shows the resize cursor and a small pill beside the border that follows the pointer. Drag the edge, or focus the handle and use Left/Right arrows (16px steps), Home, or End (480px). Double-click restores the default 256px width.
118
+
119
+ In `"resize"` mode, dragging follows the pointer and clamps to 192–480px without collapse resistance. Home and Left at the minimum keep the sidebar at 192px; double-clicking at 256px does not collapse it.
120
+
121
+ In `"collapse-and-resize"` mode, Home or Left at the minimum collapses the sidebar, and double-clicking at 256px collapses it. The collapsed desktop sidebar keeps its handle. Double-clicking it or using Enter/Space restores 256px; a single pointer click does nothing.
122
+
123
+ In `"collapse-and-resize"` mode, dragging uses a position-based resistance curve, not a delayed CSS transition. Between the collapsed width and the minimum expanded width (192px), most of the interval uses a 25% movement gain: moving the pointer 10px moves the edge only 2.5px. In the final quarter near the opposite boundary, a smooth position-based blend catches up to the pointer. The edge stops when the pointer stops; it does not catch up over time. At 192px and above it follows the pointer 1:1, up to 480px. Shrinking through the same range resists toward the collapsed position. It collapses slightly early, when the pointer enters the final 20% of that interval, with the curve already meeting the collapsed width at that point. The same pointer stays captured across both transitions, without a jump to 256px.
124
+
125
+ With collapsing enabled, releasing inside the resistance range settles back to the current mode's boundary (collapsed width or 192px).
126
+
127
+ Live drag pixels are applied through CSS custom properties rather than React state on every pointer movement. React commits the width at mode changes and when the gesture ends; ARIA values stay current during dragging. Width changes still require browser layout because the content column resizes live.
128
+
129
+ Width is not persisted across remounts or reloads. The icon rail keeps its existing collapsed width. Mobile drawers and tablet overlays keep their existing widths and do not show handles.
130
+
131
+ This applies to the built-in sidebar primitives, including `SidebarLayout.DefaultSidebar`. An arbitrary custom `sidebar` element still owns its own sizing.
132
+
77
133
  ## Extending the built-in header
78
134
 
79
135
  The supported "small customization" path is to pass `SidebarLayout.DefaultHeader` into the `header` slot and fill its `actions` cluster.
@@ -162,26 +218,33 @@ Use `body` when the stock content column is too restrictive and you need your ow
162
218
 
163
219
  ## Reading and controlling the sidebar
164
220
 
165
- Use `useAppShellSidebar()` to read and control the sidebar state instead of observing internal DOM attributes or clicking the trigger through the DOM.
221
+ Use `useAppShellSidebar()` to read and control the sidebar state instead of observing internal DOM attributes or clicking the trigger through the DOM. It also exposes the effective `sidebarBehaviour`, already normalized from either public prop, so custom sidebars do not need to handle the deprecated boolean themselves.
222
+
223
+ `open` and `setOpen()` describe desktop expansion. Drawer visibility is separate: `toggle()` selects the mobile drawer, tablet overlay, or desktop state according to the viewport. The four existing hook fields (`open`, `isMobile`, `setOpen`, and `toggle`) remain available.
166
224
 
167
225
  ```tsx
168
226
  import { useAppShellSidebar } from "@tailor-platform/app-shell";
169
227
 
170
228
  function CustomHeader() {
171
- const { open, isMobile, toggle } = useAppShellSidebar();
229
+ const { sidebarBehaviour, open, isMobile, toggle } = useAppShellSidebar();
172
230
 
173
231
  return (
174
232
  <div className="flex items-center justify-between border-b px-4 py-3">
175
233
  <button type="button" onClick={toggle}>
176
- {open ? "Hide navigation" : "Show navigation"}
234
+ Toggle navigation
177
235
  </button>
178
- <span>{isMobile ? "Mobile" : "Desktop"}</span>
236
+ <span>
237
+ {sidebarBehaviour}: {open ? "Desktop expanded" : "Desktop collapsed"}
238
+ </span>
239
+ <span>{isMobile ? "Mobile" : "Non-mobile"}</span>
179
240
  </div>
180
241
  );
181
242
  }
182
243
  ```
183
244
 
184
- Outside `SidebarLayout`, the hook returns a safe fallback (`open: true`, `isMobile: false`, no-op setters) rather than throwing.
245
+ Outside `SidebarLayout`, the hook returns a safe fallback (`sidebarBehaviour: "fixed"`, `open: true`, `isMobile: false`, no-op actions) rather than throwing. This fallback describes the absence of a managed sidebar; it is not the layout's default mode.
246
+
247
+ The desktop capabilities can be derived from the reported mode: `"collapse"` and `"collapse-and-resize"` permit collapse, while `"resize"` and `"collapse-and-resize"` permit resizing. Reading this setting does not automatically add resize behavior to arbitrary custom markup.
185
248
 
186
249
  ## Reusing the built-in breadcrumb
187
250
 
@@ -90,14 +90,14 @@ const labels = defineI18nLabels({
90
90
  - The items belong under a parent with its own icon (or landing page via `to`)
91
91
  - You need nesting
92
92
 
93
- | Feature | SidebarSection | SidebarGroup |
94
- | ------------------- | ----------------- | ------------------------- |
95
- | **Collapsible** | ❌ No | ✅ Yes |
96
- | **Heading is link** | ❌ No | Optional (`to` prop) |
97
- | **Icon** | ❌ No | Optional |
98
- | **Child styling** | Same as top level | Indented submenu |
99
- | **Icon rail** | Heading hidden | Group icon opens a flyout |
100
- | **Nesting** | Not intended | Can contain groups |
93
+ | Feature | SidebarSection | SidebarGroup |
94
+ | ------------------- | ----------------- | -------------------------------------------------- |
95
+ | **Collapsible** | ❌ No | ✅ Yes |
96
+ | **Heading is link** | ❌ No | Optional (`to` prop) |
97
+ | **Icon** | ❌ No | Optional |
98
+ | **Child styling** | Same as top level | Indented submenu |
99
+ | **Icon rail** | Heading hidden | Group icon opens a flyout (hover, or tap on touch) |
100
+ | **Nesting** | Not intended | Can contain groups |
101
101
 
102
102
  ## Accessibility
103
103
 
@@ -41,7 +41,7 @@ function BasicUsage() {
41
41
  | Sub-component | Description |
42
42
  | ------------- | -------------------------------------------------------------- |
43
43
  | `Tabs.Root` | Manages tab selection state |
44
- | `Tabs.List` | Groups the individual tab buttons |
44
+ | `Tabs.List` | Groups the individual tab buttons on one scrolling row |
45
45
  | `Tabs.Tab` | An interactive tab button that toggles the corresponding panel |
46
46
  | `Tabs.Panel` | A panel displayed when the corresponding tab is active |
47
47
 
@@ -61,7 +61,9 @@ function BasicUsage() {
61
61
 
62
62
  ### Tabs.List Props
63
63
 
64
- Accepts `className` and all standard HTML `<div>` props.
64
+ Accepts `className` and all standard HTML `<div>` props except `render`. The
65
+ HTML props apply to the `tablist` element, which is also the scroll container;
66
+ `className` applies to the visual track inside it (see [Overflow](#overflow)).
65
67
 
66
68
  ### Tabs.Tab Props
67
69
 
@@ -96,6 +98,34 @@ const [activeTab, setActiveTab] = useState("overview");
96
98
  </Tabs.Root>;
97
99
  ```
98
100
 
101
+ ## Overflow
102
+
103
+ Tabs stay on one row. When they are wider than the space available, the list
104
+ scrolls sideways instead of wrapping or growing the page. The scrollbar is
105
+ hidden: touch, a trackpad, Shift + mouse wheel, and the arrow keys scroll it,
106
+ and the tab cut off at the edge shows that there is more. Base UI scrolls the
107
+ active tab into view when the tabs mount, and scrolls the tab that the arrow
108
+ keys, Home or End move focus to into view, so keyboard navigation never lands
109
+ on a hidden tab. Nothing changes while the tabs fit.
110
+
111
+ To make this work, the `tablist` element (`data-slot="tabs-list"`) is the
112
+ scroll container, and the tabs sit in a visual track inside it
113
+ (`data-slot="tabs-list-track"`) that carries the variant styles and your
114
+ `className`. The scroll container has 4px of padding, a matching negative
115
+ margin and the same `scroll-padding`, so the tabs' focus ring is not clipped
116
+ and the list keeps its footprint. A `Tabs.List` placed next to inline content
117
+ starts on its own line.
118
+
119
+ Two cases are not scrolled into view automatically: a tab selected by changing
120
+ a controlled `value` from outside (a deep link, for example), and a list that
121
+ mounts while hidden, such as inside a closed dialog. Call
122
+ `scrollIntoView({ inline: "nearest", block: "nearest" })` on the tab if you
123
+ need either.
124
+
125
+ Scrolling keeps every tab reachable, but it does not shorten the list. If a
126
+ list regularly overflows at the widths your users have, prefer fewer tabs or
127
+ another control, such as a `Select`, over relying on the scroll.
128
+
99
129
  ## Examples
100
130
 
101
131
  ### With Disabled Tab
@@ -0,0 +1,342 @@
1
+ ---
2
+ title: Tree
3
+ description: Browse, pick one, or tick many items in a hierarchy — inline with Tree, or in a search dialog with TreePicker
4
+ ---
5
+
6
+ <!-- GENERATED by docs-kit from docs-src/components/tree.docs.outline.md — do not edit by hand. -->
7
+
8
+ # Tree
9
+
10
+ `Tree` shows your own hierarchical items (accounts with sub-accounts, warehouses with bins, categories with products) as an ARIA tree. It can be a plain browser, a single-select for master–detail views, or a multi-select checkbox form control.
11
+
12
+ The tree is the primitive, so it has no search box of its own. For the common "search, pick, then add" flow, **`TreePicker`** is a checkbox tree in a dialog with search, Cancel, and an "Add N items" button, the same split as `DateField` and `DatePicker`.
13
+
14
+ Trees are single-column. If you need a hierarchy with real columns (sorting, pinning, wide data), that's a tree grid, which belongs with the data table rather than here.
15
+
16
+ ## Import
17
+
18
+ ```tsx
19
+ import { Tree, TreePicker } from "@tailor-platform/app-shell";
20
+ ```
21
+
22
+ ## Basic Usage
23
+
24
+ Pass your own items and say how to read them with `mapItem`, as with `Select` and `Combobox`. `children` points at your own child array, and an item with no children is a leaf.
25
+
26
+ ```tsx
27
+ type Account = { code: string; name: string; children?: Account[] };
28
+
29
+ <Tree
30
+ aria-label="Chart of accounts"
31
+ className="h-96"
32
+ items={accounts}
33
+ mapItem={(account) => ({
34
+ key: account.code,
35
+ label: `${account.code} ${account.name}`,
36
+ children: account.children,
37
+ })}
38
+ onAction={(account) => navigate(`/accounts/${account.code}`)}
39
+ />;
40
+ ```
41
+
42
+ ### Item types
43
+
44
+ `Tree` takes its item type from `items`, so you don't pass one. The case that needs a type is a tree whose levels are different types, like categories holding products: type `items` as the union of every level, so `mapItem` can hand back either level's children.
45
+
46
+ ```tsx
47
+ type CatalogNode = Category | Product;
48
+
49
+ const categories: CatalogNode[] = loadCategories();
50
+ ```
51
+
52
+ Typed as `Category[]`, the item type is just `Category`, and `children: category.products` is a type error. Annotating `mapItem`'s parameter as the union works too.
53
+
54
+ ### Keys
55
+
56
+ A `key` is the item's identity across the whole tree. The same key in two places is one item, so a product listed in two collections ticks in both places at once.
57
+
58
+ When codes repeat with different meanings, like bin `A1` in two warehouses, compose a unique key. `mapItem` receives the item's `parents` for exactly this.
59
+
60
+ A key can't be a parent in one place and a leaf in another. One key is one item, and an item can't both have children and not, so those rows can't tick consistently. The tree logs a console warning naming any such keys.
61
+
62
+ ```tsx
63
+ const isWarehouse = (node: Warehouse | Bin): node is Warehouse => "bins" in node;
64
+
65
+ mapItem={(item, { parents }) =>
66
+ isWarehouse(item)
67
+ ? { key: item.id, label: item.name, children: item.bins }
68
+ : { key: `${parents.find(isWarehouse)?.id}/${item.code}`, label: item.code }
69
+ }
70
+ ```
71
+
72
+ `parents` has the same union type as `items`, so the example narrows it with a type guard rather than a cast (`parents[0] as Warehouse`). A cast tells TypeScript to trust you, and throws at runtime if there's no warehouse above. The guard has TypeScript check it, so that case stays visible (`?.id`) and you decide how to handle it.
73
+
74
+ ## Selection modes
75
+
76
+ `selectionMode` sets what clicking and Space do to a row.
77
+
78
+ | Mode | What a row does | State |
79
+ | ------------------ | ------------------------------------------------------------------------- | ------------------------ |
80
+ | `"none"` (default) | Clicking a leaf runs `onAction`, and clicking a parent opens or closes it | – |
81
+ | `"single"` | One row is selected at a time, parents included | `selectedKey` |
82
+ | `"checkbox"` | Rows have checkboxes, and the tree is a multi-select form control | `value` (a list of keys) |
83
+
84
+ Enter runs `onAction(item, key)` on any enabled row, in every mode. The props are a union on `selectionMode`, so `value` or `name` without `selectionMode="checkbox"` is a type error rather than silently ignored.
85
+
86
+ ### Single
87
+
88
+ ```tsx
89
+ const [selected, setSelected] = useState<string | null>(null);
90
+
91
+ <Tree
92
+ {...props}
93
+ selectionMode="single"
94
+ selectedKey={selected}
95
+ onSelectedKeyChange={(key) => setSelected(key)}
96
+ />;
97
+ ```
98
+
99
+ Selecting a closed parent opens it. Its caret closes it again, so selecting an open parent never hides what you were looking at.
100
+
101
+ The selected row is always revealed: when it starts out inside closed groups, or `selectedKey` changes from outside to a hidden row, its ancestors open. A group the user closes afterwards stays closed, and the tab stop then sits on its nearest visible ancestor. Pass `revealSelected={false}` to leave groups as they are.
102
+
103
+ ### Checkbox
104
+
105
+ ```tsx
106
+ const [skus, setSkus] = useState<string[]>([]);
107
+
108
+ <Tree {...props} selectionMode="checkbox" value={skus} onValueChange={setSkus} />;
109
+ ```
110
+
111
+ `onValueChange(value, { selected })` gives the new value plus the items it lists, as `{ key, item, parents }[]` in tree order. Keys in the value that aren't in `items` (not loaded yet, say) are kept as they were.
112
+
113
+ ## How ticking works
114
+
115
+ `selectionStrategy` decides how ticking spreads and what ends up in the value. The default covers the most common case: parents are just containers, and the value lists leaves.
116
+
117
+ Take a parent P with two leaves, A and B, starting from nothing ticked:
118
+
119
+ | Value | Tick P | Tick A and B | Can P be ticked on its own? |
120
+ | -------------------- | ----------- | ------------ | --------------------------- |
121
+ | `"leaves"` (default) | `[A, B]` | `[A, B]` | No |
122
+ | `"topmost"` | `[P]` | `[P]` | No |
123
+ | `"all"` | `[P, A, B]` | `[P, A, B]` | No |
124
+ | `"cascade"` | `[P, A, B]` | `[A, B]` | Yes |
125
+ | `"independent"` | `[P]` | `[A, B]` | Yes |
126
+
127
+ - **`"leaves"`, `"topmost"` and `"all"`** treat parents as containers. A parent's checkbox comes from its leaves, so unticking A after ticking P leaves P indeterminate. With `"topmost"`, `P` stands for everything beneath it.
128
+ - **`"cascade"`** makes parents items in their own right. Ticking one ticks everything beneath it too, but unticking A leaves `[P, B]`, with P still ticked.
129
+ - **`"independent"`** ticks every item on its own.
130
+
131
+ ```tsx
132
+ // Everything in a warehouse comes back as the warehouse key, not every bin.
133
+ <Tree {...props} selectionMode="checkbox" selectionStrategy="topmost" />
134
+
135
+ // A parent purchase order can be approved without its child orders.
136
+ <Tree {...props} selectionMode="checkbox" selectionStrategy="independent" />
137
+ ```
138
+
139
+ Every combination round-trips: passing a value back in shows the same ticks. With `"leaves"`, `"topmost"` or `"all"`, a parent key in the value stands for every leaf beneath it, so `defaultValue={["wh-x"]}` ticks all of Warehouse X.
140
+
141
+ ## Rows
142
+
143
+ The row shows the item's `label` by default. `renderItem` replaces it, and receives the row's state.
144
+
145
+ ```tsx
146
+ renderItem={({ item, isLeaf }) =>
147
+ isLeaf ? (
148
+ <span className="flex w-full justify-between gap-3">
149
+ <span>{item.name}</span>
150
+ <span className="text-muted-foreground tabular-nums">{money(item.price)}</span>
151
+ </span>
152
+ ) : (
153
+ <strong>{item.name}</strong>
154
+ )
155
+ }
156
+ ```
157
+
158
+ | Row state | Type | Description |
159
+ | --------------- | --------- | ------------------------------------------------------------------------------------------------- |
160
+ | `item` | `T` | Your original item. |
161
+ | `key` | `string` | The item's key. |
162
+ | `parents` | `T[]` | Ancestors, top level first. |
163
+ | `depth` | `number` | 0 for top-level rows. |
164
+ | `isLeaf` | `boolean` | The item has no children. |
165
+ | `checked` | `boolean` | Checkbox mode: the row is ticked. Where parents are containers, every enabled leaf beneath it is. |
166
+ | `indeterminate` | `boolean` | Checkbox mode, where parents are containers: some, but not all, enabled leaves beneath it are. |
167
+ | `selected` | `boolean` | Single mode: the row is the selected one. |
168
+ | `expanded` | `boolean` | The parent is open. |
169
+ | `disabled` | `boolean` | The item, an ancestor, or the whole tree is disabled. |
170
+
171
+ ## Filtering
172
+
173
+ `filter` narrows the tree to matching items plus their ancestors, and opens the groups they're in. It receives the item with its `label` and `parents`. A parent that matches with no matching children keeps all of them, so it can still be opened.
174
+
175
+ ```tsx
176
+ const [query, setQuery] = useState("");
177
+ const filter = useMemo(
178
+ () => (query ? (_: Account, { label }: { label: string }) => label.includes(query) : undefined),
179
+ [query],
180
+ );
181
+
182
+ <Input value={query} onChange={(event) => setQuery(event.target.value)} />
183
+ <Tree {...props} filter={filter} />
184
+ ```
185
+
186
+ Groups a filter opened can still be closed. They stay closed until the matches change, so an inline `filter` recreated on every render won't reopen them.
187
+
188
+ In checkbox mode, ticking a group while filtering acts on what's visible: it ticks the group's matching leaves, not the hidden ones. The group's checkbox still reflects the whole group, so it shows as partly ticked until every leaf beneath it is. Press it again to clear the visible matches.
189
+
190
+ ## Forms
191
+
192
+ In checkbox mode, `Tree` works like `Select` and the other form controls: as a plain form field, inside `Field.Root`, and with React Hook Form.
193
+
194
+ ```tsx
195
+ <form action={saveCount}>
196
+ <Field.Root name="bins">
197
+ <Field.Label>Bins to count</Field.Label>
198
+ <Tree {...props} selectionMode="checkbox" required />
199
+ <Field.Description>Pick at least one bin.</Field.Description>
200
+ </Field.Root>
201
+ <Button type="submit">Start count</Button>
202
+ </form>
203
+ ```
204
+
205
+ - **Submitting:** `name` (or the `Field.Root`'s name) renders one hidden input per value key, so the server reads `formData.getAll("bins")`. `itemToStringValue(item, { parents })` changes the submitted string.
206
+ - **`required`** blocks submission until at least one item is ticked.
207
+ - **Inside `Field.Root`**, `Field.Label` names the tree, `Field.Description` and `Field.Error` are announced with it, and an invalid field sets `data-invalid` on the root.
208
+ - **`disabled`** (or a disabled `Field.Root`) locks the whole tree and leaves it out of submission.
209
+ - **`onBlur`** fires once focus leaves the tree, not between rows. React Hook Form uses it to mark the field touched.
210
+ - **Enter** runs `onAction`, or submits the owning form when there's no `onAction`, like Enter in a text input.
211
+
212
+ Only checkbox mode is a form control. In `"none"` and `"single"` modes the tree doesn't register with `Field`.
213
+
214
+ ## Disabled items
215
+
216
+ Return `disabled: true` from `mapItem` to lock an item. A disabled parent locks everything beneath it, and `disabled` on the tree locks every row. A locked row can't be ticked or selected, and Enter doesn't run `onAction` on it. A disabled item that's already ticked stays ticked.
217
+
218
+ ```tsx
219
+ mapItem={(bin) => ({ key: bin.code, label: bin.code, disabled: bin.status === "counting" })}
220
+ ```
221
+
222
+ How a parent behaves next to disabled children depends on whether it's a container or an item:
223
+
224
+ | Mode | Ticking a parent | A parent whose children are all disabled |
225
+ | -------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------- |
226
+ | Checkbox, `"leaves"` / `"topmost"` / `"all"` | Skips disabled leaves. It counts as ticked once every _enabled_ leaf beneath it is. | Locked, since there's nothing it could tick. |
227
+ | Checkbox, `"cascade"` | Ticks itself and every enabled item beneath it. | Usable. Ticking it ticks only itself. |
228
+ | Checkbox, `"independent"` | Ticks only itself. | Usable. |
229
+ | `"single"` and `"none"` selection modes | – | Usable: it can be selected or run. |
230
+
231
+ ## TreePicker
232
+
233
+ A checkbox tree in a dialog, with search. It takes the same item, row and ticking props as `Tree`, plus the search and commit props. Each open starts empty, and `onCommit` receives the value's items in tree order. Ticking a group while searching ticks its matching items, as described under [Filtering](#filtering).
234
+
235
+ ```tsx
236
+ const isProduct = (item: Category | Product): item is Product => "sku" in item;
237
+
238
+ <TreePicker
239
+ open={open}
240
+ onOpenChange={setOpen}
241
+ title="Add items from catalogue"
242
+ items={categories}
243
+ mapItem={mapCatalogItem}
244
+ renderItem={renderCatalogItem}
245
+ matchesSearch={(item, query) => item.name.toLowerCase().includes(query.toLowerCase())}
246
+ onCommit={(picked) => addToOrder(picked.flatMap(({ item }) => (isProduct(item) ? [item] : [])))}
247
+ />;
248
+ ```
249
+
250
+ `onCommit`'s items have the union type too, so narrow them with a type guard rather than casting to `Product`. With the default `"leaves"` only products come back, but a guard keeps that true if you later switch to `"topmost"` or `"all"`, which can return categories.
251
+
252
+ ## Props
253
+
254
+ ### Shared (Tree and TreePicker)
255
+
256
+ | Prop | Type | Default | Description |
257
+ | ------------ | ---------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------- |
258
+ | `items` | `ReadonlyArray<T>` | required | Your top-level items. |
259
+ | `mapItem` | `(item: T, { parents }) => { key, label, children?, disabled? }` | required | Reads an item's key, label, children and disabled state. |
260
+ | `renderItem` | `(row) => ReactNode` | the item's `label` | The row's content. |
261
+ | `emptyText` | `ReactNode` | "No items." | Shown when there's nothing to show ("No matching items." while filtering). |
262
+
263
+ ### Tree
264
+
265
+ | Prop | Type | Default | Description |
266
+ | -------------------------------- | ------------------------------------------ | -------- | ----------------------------------------------------------------------- |
267
+ | `selectionMode` | `"none" \| "single" \| "checkbox"` | `"none"` | What clicking and Space do to a row. |
268
+ | `onAction` | `(item: T, key: string) => void` | – | Enter on a row. In `"none"` mode, a click on a leaf too. |
269
+ | `filter` | `(item: T, { label, parents }) => boolean` | – | Shows only matches and their ancestors. |
270
+ | `disabled` | `boolean` | `false` | Locks every row, and in checkbox mode leaves it out of form submission. |
271
+ | `aria-label` / `aria-labelledby` | `string` | – | Names the tree, when there's no `Field.Label`. |
272
+ | `className` | `string` | – | Merged onto the root. Give it a height for long trees. |
273
+
274
+ With `selectionMode="single"`:
275
+
276
+ | Prop | Type | Default | Description |
277
+ | ------------------------------------ | -------------------------------- | ------- | ------------------------------------------------------ |
278
+ | `selectedKey` / `defaultSelectedKey` | `string \| null` | `null` | Controlled / initial selected row. |
279
+ | `onSelectedKeyChange` | `(key: string, item: T) => void` | – | Fires when the user selects a row. |
280
+ | `revealSelected` | `boolean` | `true` | Opens the selected row\'s ancestors when it\'s hidden. |
281
+
282
+ With `selectionMode="checkbox"`:
283
+
284
+ | Prop | Type | Default | Description |
285
+ | ---------------------------- | -------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------- |
286
+ | `value` / `defaultValue` | `string[]` | – | Controlled / initial value: the ticked keys. |
287
+ | `onValueChange` | `(value, { selected }) => void` | – | Every change, plus the items the value lists. |
288
+ | `selectionStrategy` | `"leaves" \| "topmost" \| "all" \| "cascade" \| "independent"` | `"leaves"` | How ticking works and what the value lists. See [How ticking works](#how-ticking-works). |
289
+ | `name` / `form` | `string` | – | Hidden inputs for native form submission. `Field.Root`'s name wins. |
290
+ | `itemToStringValue` | `(item, { parents }) => string` | the key | The string each hidden input carries. |
291
+ | `required` | `boolean` | `false` | At least one item must be ticked. |
292
+ | `id` / `inputRef` / `onBlur` | – | – | Form control id, the hidden proxy input, and blur from the whole tree. |
293
+
294
+ ### TreePicker
295
+
296
+ | Prop | Type | Default | Description |
297
+ | ------------------- | ------------------------------------------------------------------ | ---------------- | ------------------------------------------------------------------- |
298
+ | `open` | `boolean` | required | Controls dialog visibility. |
299
+ | `onOpenChange` | `(open: boolean) => void` | required | Fires on every user-driven open or close, including after a commit. |
300
+ | `onCommit` | `(selected: TreeSelectedItem<T>[]) => void` | required | Receives the value's items in tree order. |
301
+ | `selectionStrategy` | as on `Tree` | `"leaves"` | How ticking works while picking. |
302
+ | `matchesSearch` | `(item: T, query: string, context: { label, parents }) => boolean` | match on `label` | Search predicate. An item is kept when it or a descendant matches. |
303
+ | `filterSlot` | `ReactNode` | – | Beside the search input, e.g. a category filter. |
304
+ | `searchPlaceholder` | `string` | `"Search"` | Search input placeholder. |
305
+ | `title` | `ReactNode` | `"Select items"` | Dialog title. Also labels the tree for assistive tech. |
306
+ | `ctaLabel` | `(count: number) => ReactNode` | `"Add N items"` | Primary button label. |
307
+ | `className` | `string` | – | Merged onto the dialog panel. |
308
+
309
+ The default labels are localised (English and Japanese) through the app's locale, like DataTable's. Passing a label prop overrides it.
310
+
311
+ ## Behaviour
312
+
313
+ **Keyboard.** The tree is a `role="tree"` with roving focus. Arrow Up / Down move between visible rows, Arrow Right opens a closed parent or steps into it, Arrow Left closes or steps out to the parent, and Home / End jump to the ends. Space selects or ticks the focused row, and Enter runs `onAction` (in the picker, it commits).
314
+
315
+ **Mouse.** In checkbox mode, clicking a group row opens or closes it and its checkbox ticks everything inside, while clicking an item row ticks it. In single mode, clicking selects. In `"none"` mode, clicking a leaf runs `onAction`.
316
+
317
+ **Picker search.** Matches stay visible with their ancestors opened. Escape clears the search first, and a second Escape closes the dialog. Enter in the search box or in a `filterSlot` control (a menu or select) is left to that control.
318
+
319
+ **Picker height.** The picker keeps one height and the list scrolls inside it, so opening or closing a group never resizes the dialog or moves what's on screen.
320
+
321
+ ## Loading an async catalogue
322
+
323
+ There's deliberately no async variant yet: filtering and auto-expanding need the whole tree up front. Fetch once before or on open, and drive `emptyText` from your own loading flag. Keys in `value` for items that haven't loaded yet are kept, so a controlled selection survives the load.
324
+
325
+ ```tsx
326
+ const { data, isLoading } = useCatalogQuery();
327
+
328
+ <TreePicker
329
+ open={open}
330
+ onOpenChange={setOpen}
331
+ items={data ?? []}
332
+ mapItem={mapCatalogItem}
333
+ emptyText={isLoading ? "Loading catalogue…" : undefined}
334
+ onCommit={handleCommit}
335
+ />;
336
+ ```
337
+
338
+ ## Related
339
+
340
+ - [Combobox](./combobox.md) — inline single or multi select for flat option lists
341
+ - [Select](./select.md) — dropdown select, with the same `items` + `mapItem` pattern
342
+ - [Dialog](./dialog.md) — the modal shell `TreePicker` composes
@@ -18,14 +18,14 @@ import { WithGuard, pass, hidden } from "@tailor-platform/app-shell";
18
18
  ## Basic Usage
19
19
 
20
20
  ```tsx
21
- import { WithGuard, pass, hidden } from "@tailor-platform/app-shell";
21
+ import { WithGuard, pass, hidden, Text } from "@tailor-platform/app-shell";
22
22
 
23
23
  const isAdmin = ({ context }) => (context.currentUser?.role === "admin" ? pass() : hidden());
24
24
 
25
25
  function AdminPanel() {
26
26
  return (
27
27
  <div>
28
- <h1>Dashboard</h1>
28
+ <Text.h1>Dashboard</Text.h1>
29
29
 
30
30
  <WithGuard guards={[isAdmin]}>
31
31
  <Button>Delete All Users</Button>
@@ -113,7 +113,7 @@ const isAdmin = ({ context }) => (context.currentUser?.role === "admin" ? pass()
113
113
  Show alternative content when guard fails:
114
114
 
115
115
  ```tsx
116
- <WithGuard guards={[isAdmin]} fallback={<p>You need admin access to view this.</p>}>
116
+ <WithGuard guards={[isAdmin]} fallback={<Text.p>You need admin access to view this.</Text.p>}>
117
117
  <AdminPanel />
118
118
  </WithGuard>
119
119
  ```
@@ -212,8 +212,8 @@ const isPremium = ({ context }) =>
212
212
  guards={[isPremium]}
213
213
  fallback={
214
214
  <Card>
215
- <h3>Premium Feature</h3>
216
- <p>Upgrade to access advanced analytics.</p>
215
+ <Text.h3>Premium Feature</Text.h3>
216
+ <Text.p>Upgrade to access advanced analytics.</Text.p>
217
217
  <Button>Upgrade Now</Button>
218
218
  </Card>
219
219
  }
@@ -250,7 +250,7 @@ const AuthGate = () => {
250
250
  if (error) {
251
251
  return (
252
252
  <div>
253
- <p>Sign-in failed: {error}</p>
253
+ <Text.p>Sign-in failed: {error}</Text.p>
254
254
  <button onClick={() => void login()}>Try again</button>
255
255
  </div>
256
256
  );
@@ -131,7 +131,7 @@ Reach for `TestRouter` when the component under test is the subject; mount `AppS
131
131
  ## Example
132
132
 
133
133
  ```tsx
134
- import { useNavigate, useParams, useLocation, Link } from "@tailor-platform/app-shell";
134
+ import { useNavigate, useParams, useLocation, Link, Text } from "@tailor-platform/app-shell";
135
135
 
136
136
  const MyComponent = () => {
137
137
  const navigate = useNavigate();
@@ -145,8 +145,8 @@ const MyComponent = () => {
145
145
 
146
146
  return (
147
147
  <div>
148
- <p>Current path: {location.pathname}</p>
149
- <p>Route param ID: {id}</p>
148
+ <Text.p>Current path: {location.pathname}</Text.p>
149
+ <Text.p>Route param ID: {id}</Text.p>
150
150
 
151
151
  {/* Client-side link */}
152
152
  <Link to="/products">View Products</Link>