@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.
- package/dist/app-shell-core.css +2 -2
- package/dist/app-shell.d.ts +807 -130
- package/dist/app-shell.js +12419 -10930
- package/dist/{appshell-DQTz-QwK.js → appshell-B4QkQvXF.js} +377 -360
- package/dist/fonts/inter.css +7 -7
- package/dist/fonts/noto-sans-jp.css +116 -116
- package/dist/testing.d.ts +119 -89
- package/dist/testing.js +1 -1
- package/dist/vite-plugin.d.ts +119 -89
- package/package.json +10 -8
- package/skills/app-shell-patterns/SKILL.md +4 -3
- package/skills/app-shell-patterns/references/api/create-ai-gateway-client.md +32 -2
- package/skills/app-shell-patterns/references/api/define-module.md +5 -5
- package/skills/app-shell-patterns/references/api/router/use-params.md +2 -2
- package/skills/app-shell-patterns/references/api/use-ai-chat.md +46 -8
- package/skills/app-shell-patterns/references/api/use-app-shell-config.md +4 -4
- package/skills/app-shell-patterns/references/api/use-app-shell-data.md +5 -5
- package/skills/app-shell-patterns/references/api/use-app-shell.md +2 -2
- package/skills/app-shell-patterns/references/api/use-auth.md +3 -3
- package/skills/app-shell-patterns/references/api/use-route-error.md +12 -12
- package/skills/app-shell-patterns/references/components/action-panel.md +4 -2
- package/skills/app-shell-patterns/references/components/activity-card.md +3 -3
- package/skills/app-shell-patterns/references/components/ai-chat.md +37 -3
- package/skills/app-shell-patterns/references/components/app-shell.md +6 -5
- package/skills/app-shell-patterns/references/components/card.md +2 -2
- package/skills/app-shell-patterns/references/components/data-table.md +87 -31
- package/skills/app-shell-patterns/references/components/date-picker.md +2 -2
- package/skills/app-shell-patterns/references/components/default-sidebar.md +2 -2
- package/skills/app-shell-patterns/references/components/dialog.md +8 -0
- package/skills/app-shell-patterns/references/components/filter-rail.md +10 -3
- package/skills/app-shell-patterns/references/components/form.md +5 -5
- package/skills/app-shell-patterns/references/components/global-header-layout.md +31 -8
- package/skills/app-shell-patterns/references/components/layout.md +24 -17
- package/skills/app-shell-patterns/references/components/select.md +8 -0
- package/skills/app-shell-patterns/references/components/sidebar-group.md +9 -7
- package/skills/app-shell-patterns/references/components/sidebar-layout.md +74 -11
- package/skills/app-shell-patterns/references/components/sidebar-section.md +8 -8
- package/skills/app-shell-patterns/references/components/tabs.md +32 -2
- package/skills/app-shell-patterns/references/components/tree.md +342 -0
- package/skills/app-shell-patterns/references/components/with-guard.md +5 -5
- package/skills/app-shell-patterns/references/concepts/authentication.md +1 -1
- package/skills/app-shell-patterns/references/concepts/routing-navigation.md +3 -3
- package/skills/app-shell-patterns/references/concepts/styling-theming.md +13 -13
- package/skills/app-shell-patterns/references/migrations.md +20 -0
- package/skills/app-shell-patterns/references/pages/document-detail.md +8 -12
- 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
|
|
52
|
-
|
|
|
53
|
-
| `sidebar`
|
|
54
|
-
| `defaultOpen`
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
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
|
-
|
|
234
|
+
Toggle navigation
|
|
177
235
|
</button>
|
|
178
|
-
<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
|
|
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
|
}
|
|
@@ -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>
|