torch-glare 2.4.4 → 2.5.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/apps/lib/components/DataViews/{badgeAdapter.ts → badge.ts} +2 -2
- package/apps/lib/components/DataViews/cell.tsx +324 -0
- package/apps/lib/components/DataViews/context.ts +144 -0
- package/apps/lib/components/DataViews/data-views.tsx +383 -0
- package/apps/lib/components/DataViews/filters/children.tsx +98 -0
- package/apps/lib/components/DataViews/filters/custom.tsx +34 -0
- package/apps/lib/components/DataViews/filters/filters.tsx +163 -0
- package/apps/lib/components/DataViews/filters/index.ts +4 -0
- package/apps/lib/components/DataViews/filters/labelled.tsx +20 -0
- package/apps/lib/components/DataViews/filters/presets.tsx +65 -0
- package/apps/lib/components/DataViews/filters/summary.tsx +65 -0
- package/apps/lib/components/DataViews/filters/sync.tsx +35 -0
- package/apps/lib/components/DataViews/filters/values.ts +173 -0
- package/apps/lib/components/DataViews/header.tsx +217 -0
- package/apps/lib/components/DataViews/hooks/index.ts +5 -0
- package/apps/lib/components/DataViews/hooks/useActiveRow.ts +22 -0
- package/apps/lib/components/DataViews/hooks/useControllable.ts +52 -0
- package/apps/lib/components/DataViews/index.ts +74 -26
- package/apps/lib/components/DataViews/panel/columns.tsx +153 -0
- package/apps/lib/components/DataViews/panel/controls.tsx +106 -0
- package/apps/lib/components/DataViews/panel/index.ts +3 -0
- package/apps/lib/components/DataViews/panel/panel.tsx +164 -0
- package/apps/lib/components/DataViews/panel/saved-views.tsx +67 -0
- package/apps/lib/components/DataViews/panel/section.tsx +79 -0
- package/apps/lib/components/DataViews/panel/sort.tsx +42 -0
- package/apps/lib/components/DataViews/panel/tab.tsx +31 -0
- package/apps/lib/components/DataViews/slots.ts +63 -0
- package/apps/lib/components/DataViews/states.tsx +38 -0
- package/apps/lib/components/DataViews/types.ts +485 -178
- package/apps/lib/components/DataViews/views/board-view.tsx +379 -0
- package/apps/lib/components/DataViews/views/card-rows.tsx +36 -0
- package/apps/lib/components/DataViews/views/inbox-view.tsx +257 -0
- package/apps/lib/components/DataViews/views/pane-views.tsx +192 -0
- package/apps/lib/components/DataViews/views/table-view.tsx +426 -0
- package/apps/lib/components/DataViews/views/tree-view.tsx +365 -0
- package/apps/lib/components/FormBuilder/context.ts +20 -6
- package/apps/lib/components/FormBuilder/field-kind.ts +28 -0
- package/apps/lib/components/FormBuilder/fields/DateField.tsx +3 -3
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +7 -6
- package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +30 -4
- package/apps/lib/components/FormBuilder/fields/SelectField.tsx +7 -7
- package/apps/lib/components/FormBuilder/fields/TableField.tsx +80 -52
- package/apps/lib/components/FormBuilder/fields/TextField.tsx +9 -9
- package/apps/lib/components/FormBuilder/form-builder.tsx +66 -6
- package/apps/lib/components/FormBuilder/index.ts +3 -1
- package/apps/lib/components/FormBuilder/types.ts +40 -0
- package/apps/lib/components/Input.tsx +3 -0
- package/apps/lib/components/SearchableTable.tsx +5 -4
- package/apps/lib/components/SectionBlock.tsx +58 -11
- package/apps/lib/components/Select.tsx +3 -1
- package/apps/lib/components/TabSwitch.tsx +16 -4
- package/apps/lib/components/Table.tsx +265 -67
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +6 -3
- package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +16 -14
- package/apps/lib/components/TreeFolder/index.ts +1 -1
- package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +70 -207
- package/apps/lib/hooks/useDragDrop.tsx +365 -0
- package/apps/lib/hooks/useInfiniteScroll.ts +108 -0
- package/apps/lib/registry.json +159 -4
- package/apps/lib/tsconfig.tsbuildinfo +1 -1
- package/apps/lib/utils/dataViews/path.ts +67 -0
- package/apps/lib/utils/dataViews/query.ts +73 -0
- package/apps/lib/utils/dataViews/types.ts +187 -0
- package/docs/components/breadcrumb.md +1 -1
- package/docs/components/button-group.md +1 -1
- package/docs/components/button.md +1 -1
- package/docs/components/card.md +1 -1
- package/docs/components/checkbox.md +1 -1
- package/docs/components/data-views/backend-response.md +324 -0
- package/docs/components/data-views/examples/a11y-rtl.md +250 -0
- package/docs/components/data-views/examples/api-orders-route.md +130 -0
- package/docs/components/data-views/examples/fields.md +362 -0
- package/docs/components/data-views/examples/filters.md +308 -0
- package/docs/components/data-views/examples/inbox-routing.md +218 -0
- package/docs/components/data-views/examples/index.md +29 -0
- package/docs/components/data-views/examples/overview.md +244 -0
- package/docs/components/data-views/examples/panel.md +212 -0
- package/docs/components/data-views/examples/scale.md +231 -0
- package/docs/components/data-views/examples/server-side.md +210 -0
- package/docs/components/data-views/examples/state.md +250 -0
- package/docs/components/data-views/examples/tree-custom.md +388 -0
- package/docs/components/data-views/examples/view-registry.md +313 -0
- package/docs/components/data-views/examples/views.md +534 -0
- package/docs/components/data-views/guide.md +405 -0
- package/docs/components/data-views/index.md +1504 -0
- package/docs/components/data-views/migration.md +79 -0
- package/docs/components/date-picker.md +0 -1
- package/docs/components/form-builder.md +19 -8
- package/docs/components/form-renderer.md +2 -1
- package/docs/components/form.md +1 -1
- package/docs/components/input-field.md +1 -1
- package/docs/components/input-otp.md +1 -1
- package/docs/components/input.md +1 -1
- package/docs/components/labeled-check-box.md +1 -1
- package/docs/components/labeled-radio.md +1 -1
- package/docs/components/radio-card.md +1 -1
- package/docs/components/radio.md +1 -1
- package/docs/components/search-field.md +1 -1
- package/docs/components/section-block.md +79 -3
- package/docs/components/select.md +1 -1
- package/docs/components/simple-select.md +1 -1
- package/docs/components/switch.md +1 -1
- package/docs/components/tab-switch.md +1 -1
- package/docs/components/table.md +45 -8
- package/docs/components/text-editor.md +1 -1
- package/docs/components/textarea.md +1 -1
- package/docs/components/toggle-button.md +1 -1
- package/docs/components/toggle.md +1 -1
- package/docs/components/tree-folder.md +110 -0
- package/docs/how-to/forms-with-form-builder.md +6 -4
- package/docs/reference/components.md +16 -6
- package/docs/tutorials/component-composition.md +11 -13
- package/package.json +3 -2
- package/apps/lib/components/DataViews/DataViewRadio.tsx +0 -49
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +0 -393
- package/apps/lib/components/DataViews/DataViewsHeader.tsx +0 -207
- package/apps/lib/components/DataViews/DataViewsLayout.tsx +0 -332
- package/apps/lib/components/DataViews/FilterPanel.tsx +0 -493
- package/apps/lib/components/DataViews/HeaderSearch.tsx +0 -93
- package/apps/lib/components/DataViews/InboxView.tsx +0 -463
- package/apps/lib/components/DataViews/InboxViewCard.tsx +0 -127
- package/apps/lib/components/DataViews/KanbanView.tsx +0 -336
- package/apps/lib/components/DataViews/PanelControls.tsx +0 -39
- package/apps/lib/components/DataViews/SettingsPanel.tsx +0 -279
- package/apps/lib/components/DataViews/TableView.tsx +0 -212
- package/apps/lib/components/DataViews/TreeView.tsx +0 -364
- package/apps/lib/components/DataViews/fieldRenderers.tsx +0 -299
- package/apps/lib/components/DataViews/filters/DatePickerRangeFilter.tsx +0 -87
- package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +0 -120
- package/apps/lib/components/DataViews/filters/PresetChips.tsx +0 -45
- package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +0 -165
- package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +0 -50
- package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +0 -74
- package/apps/lib/hooks/useDataViewsState.ts +0 -175
- package/apps/lib/utils/dataViews/columnUtils.ts +0 -132
- package/apps/lib/utils/dataViews/fieldUtils.ts +0 -197
- package/apps/lib/utils/dataViews/nestedDataUtils.tsx +0 -371
- package/apps/lib/utils/dataViews/pathUtils.ts +0 -139
- package/apps/lib/utils/dataViews/rangeUtils.ts +0 -234
- package/apps/lib/utils/dataViews/treeUtils.ts +0 -396
- package/docs/components/data-views-config-panel.md +0 -208
- package/docs/components/data-views-layout.md +0 -291
- package/docs/components/inbox-view.md +0 -170
- package/docs/components/kanban-view.md +0 -135
- package/docs/components/table-view.md +0 -141
- package/docs/components/tree-view.md +0 -147
- package/docs/how-to/data-views-from-backend-response.md +0 -194
|
@@ -0,0 +1,1504 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: DataViews
|
|
3
|
+
description: One dataset shown as a table, kanban board, inbox or tree — with a shared header, view switcher, filters, settings rail, scroll loading and drag-and-drop.
|
|
4
|
+
group: Data Display
|
|
5
|
+
keywords: [data-views, dataviews, table, board, kanban, inbox, tree, views, filters, panel, columns, saved-views, infinite-scroll, virtualization, drag-drop, server-side]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# DataViews
|
|
9
|
+
|
|
10
|
+
> One dataset, several ways to look at it — table, board, tree, inbox — behind a shared header,
|
|
11
|
+
> filter set and settings rail. It is pure UI: you query, it paints.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
TORCH Glare is a copy-in library: the CLI copies this component's source into your project
|
|
16
|
+
(you do **not** install it from the npm package). Run `init` once, then `add`:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx torch-glare@latest init
|
|
20
|
+
npx torch-glare@latest add DataViews
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`DataViews` is a folder component — the CLI copies the whole directory, plus everything it depends
|
|
24
|
+
on: `Table`, `Checkbox`, `Badge`, `Button`, `Switch`, `Divider`, `Skeleton`, `TabSwitch`, `Avatar`,
|
|
25
|
+
`FormBuilder`, `TreeFolder`, the `DataViewCard` layout, the `useDragDrop` and `useInfiniteScroll`
|
|
26
|
+
hooks, and the `dataViews` utilities.
|
|
27
|
+
|
|
28
|
+
## Import
|
|
29
|
+
|
|
30
|
+
Import from your project's local path — the alias configured in `glare.json` (e.g. `@/*`).
|
|
31
|
+
**One path covers everything**: the component, the hooks, the query helpers, and the types you
|
|
32
|
+
construct.
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import {
|
|
36
|
+
DataViews, // the compound root — every part hangs off it
|
|
37
|
+
emptyQuery, queryToParams, parseQuery, // the query's wire format, both directions
|
|
38
|
+
useDataViewsData, useDataViewsView, // read the component's state from your own part
|
|
39
|
+
useDataViewsFilters, useDataViewsPanel, useDataViewsPanelTabs,
|
|
40
|
+
useActiveRow, // the row behind `activeId`
|
|
41
|
+
Cell, // paint one field the way the views paint it
|
|
42
|
+
markView, markHeader, markPanel, // register a part of your own
|
|
43
|
+
SkeletonBar, skeletonKeys, // the loading pieces every view is built from
|
|
44
|
+
getByPath, formatPathLabel, defaultGetRowId, // read a value by dotted path
|
|
45
|
+
buildCardRows, resolveBadgeVariant,
|
|
46
|
+
} from "@/components/DataViews";
|
|
47
|
+
|
|
48
|
+
import type {
|
|
49
|
+
Row, FieldConfig, FieldType, DataViewsQuery, // the shapes you construct
|
|
50
|
+
RowGroup, TreeNode, MoveIntent, Sort, ColumnState,
|
|
51
|
+
FilterState, FilterValue, Preset, SavedView,
|
|
52
|
+
} from "@/components/DataViews";
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Two things come from elsewhere, because they are not DataViews' own:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { DataViewCard } from "@/layouts/DataViewCard"; // the card the board and pane paint
|
|
59
|
+
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
|
|
60
|
+
import { useDragDrop } from "@/hooks/useDragDrop";
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Examples
|
|
64
|
+
|
|
65
|
+
Four screens, each a complete page that ships with the docs. Start from
|
|
66
|
+
[`overview`](./examples/overview.md) — it is every part at once.
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
const [query, setQuery] = useState(emptyQuery());
|
|
70
|
+
const { data, isPending } = useQuery({
|
|
71
|
+
queryKey: ["orders", query],
|
|
72
|
+
queryFn: () => fetch(`/api/orders?${queryToParams(query)}`).then((r) => r.json()),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
<DataViews
|
|
76
|
+
rows={data?.rows ?? []}
|
|
77
|
+
total={data?.total ?? 0}
|
|
78
|
+
fields={fields}
|
|
79
|
+
loading={isPending}
|
|
80
|
+
onQueryChange={setQuery}
|
|
81
|
+
>
|
|
82
|
+
<DataViews.Header title="Orders">
|
|
83
|
+
<DataViews.ViewSwitch />
|
|
84
|
+
<DataViews.Search />
|
|
85
|
+
<DataViews.PanelToggle />
|
|
86
|
+
</DataViews.Header>
|
|
87
|
+
|
|
88
|
+
<DataViews.Table selectable />
|
|
89
|
+
<DataViews.Board groups={groups} onRowMove={move} />
|
|
90
|
+
|
|
91
|
+
<DataViews.Panel>
|
|
92
|
+
<DataViews.Panel.Tab value="config" label="Config." icon={<Settings />}>
|
|
93
|
+
<DataViews.Panel.SavedViews views={saved} onSave={persist} />
|
|
94
|
+
<DataViews.Panel.Columns />
|
|
95
|
+
<DataViews.Panel.Sort />
|
|
96
|
+
</DataViews.Panel.Tab>
|
|
97
|
+
<DataViews.Panel.Tab value="filters" label="Filters" icon={<Filter />}>
|
|
98
|
+
<DataViews.Filters title={null} className="border-b-0 p-0">
|
|
99
|
+
<FormBuilder.MultiSelect name="status" label="Status" options={STATUS} />
|
|
100
|
+
<FormBuilder.Slider name="total" label="Total" range min={0} max={15000} />
|
|
101
|
+
</DataViews.Filters>
|
|
102
|
+
</DataViews.Panel.Tab>
|
|
103
|
+
</DataViews.Panel>
|
|
104
|
+
</DataViews>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## The three rules
|
|
108
|
+
|
|
109
|
+
**A part exists because you rendered it.** There is no `views={{ table: true }}` map and no
|
|
110
|
+
`showFilters` flag. Render `<DataViews.Tree/>` and a Tree tab appears; wrap it in
|
|
111
|
+
`{canSeeTree && …}` and it disappears, switching away from it if it was open. The same goes for
|
|
112
|
+
panel tabs.
|
|
113
|
+
|
|
114
|
+
**It is pure UI.** DataViews never filters, searches, sorts, groups, paginates, builds a tree,
|
|
115
|
+
infers a schema or mutates a row. It paints the `rows` you hand it, in the order you hand them.
|
|
116
|
+
Nothing on screen moves until you hand back different data — which is the point: a drag that fails
|
|
117
|
+
to save leaves the board showing the truth. So you supply `rows` (already queried), `total`,
|
|
118
|
+
`fields`, filter options and slider bounds, board `groups` and tree `nodes`.
|
|
119
|
+
|
|
120
|
+
**Only the query leaves.** Search, filters, sort, page and page size are one object reported
|
|
121
|
+
through `onQueryChange`, because they are one question — and because a query with a new filter and
|
|
122
|
+
a stale page number is not a query anyone meant to ask. Everything else the user can change (which
|
|
123
|
+
view is showing, which tab is open, how columns are arranged, what is selected, which row is open)
|
|
124
|
+
changes nothing but the picture, so the component keeps it.
|
|
125
|
+
|
|
126
|
+
| The user… | You get |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| types, filters, sorts | `onQueryChange(query)` → go and fetch |
|
|
129
|
+
| reaches the end of a list | `onLoadMore()` → fetch the next page and append it |
|
|
130
|
+
| drags a card, a row or a node | `onRowMove` / `onNodeMove` → save, hand back updated rows |
|
|
131
|
+
| selects rows / opens one | `onSelectionChange` / `onActiveIdChange` — told, not asked |
|
|
132
|
+
|
|
133
|
+
Changing a filter resets `page` to 1 internally. Only the component can do that: by the time you
|
|
134
|
+
see the change, "new filter" and "new page" have already become one object.
|
|
135
|
+
|
|
136
|
+
## Empty and loading
|
|
137
|
+
|
|
138
|
+
Neither is a part you render. When there is nothing to show, the view shows **nothing** — the table
|
|
139
|
+
keeps its header band and has no rows; the board keeps its columns and has no cards. While
|
|
140
|
+
`loading` is set, each view paints a **skeleton in its own shape**: the table shimmers rows at the
|
|
141
|
+
real row height and column widths, the board shimmers cards inside its columns.
|
|
142
|
+
|
|
143
|
+
## Large datasets
|
|
144
|
+
|
|
145
|
+
Rows load **as you scroll**; there is no pager. Hand over `onLoadMore` and append each page to
|
|
146
|
+
`rows`. Whether there is more is not a prop — it is `rows.length < total`, which the component
|
|
147
|
+
already knows.
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
const { data, fetchNextPage, isPending, isFetchingNextPage } = useInfiniteQuery({
|
|
151
|
+
queryKey: ["orders", { ...query, page: undefined }], // not `page` — that is what pageParam drives
|
|
152
|
+
initialPageParam: 1,
|
|
153
|
+
queryFn: ({ pageParam }) =>
|
|
154
|
+
fetch(`/api/orders?${queryToParams({ ...query, page: pageParam })}`).then((r) => r.json()),
|
|
155
|
+
getNextPageParam: (last, pages) => {
|
|
156
|
+
const loaded = pages.reduce((n, p) => n + p.rows.length, 0);
|
|
157
|
+
return loaded < last.total ? pages.length + 1 : undefined;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
<DataViews
|
|
162
|
+
rows={data?.pages.flatMap((p) => p.rows) ?? []}
|
|
163
|
+
total={data?.pages[0]?.total ?? 0}
|
|
164
|
+
loading={isPending}
|
|
165
|
+
onLoadMore={fetchNextPage}
|
|
166
|
+
loadingMore={isFetchingNextPage}
|
|
167
|
+
fields={fields}
|
|
168
|
+
onQueryChange={setQuery}
|
|
169
|
+
/>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The table also **virtualizes past 300 rows** — below that it renders every row, so small tables,
|
|
173
|
+
row drag and column resize behave exactly as before; above it the DOM holds a window of rows
|
|
174
|
+
however many are loaded. The board and inbox load on scroll but do not virtualize. The **tree does
|
|
175
|
+
neither**: a tree wants its children fetched when a node is expanded, not its siblings paged in,
|
|
176
|
+
and that is not built.
|
|
177
|
+
|
|
178
|
+
## Views
|
|
179
|
+
|
|
180
|
+
| Part | What it renders | You supply | Runnable |
|
|
181
|
+
| --- | --- | --- | --- |
|
|
182
|
+
| `DataViews.Table` | rows and columns, virtualized past 300 | nothing beyond `rows` | [`views`](./examples/views.md) |
|
|
183
|
+
| `DataViews.Board` | a kanban board | `groups` — it never groups rows itself | [`views`](./examples/views.md) |
|
|
184
|
+
| `DataViews.Inbox` | a master list beside a detail pane | the pane, as `children` | [`inbox-routing`](./examples/inbox-routing.md) |
|
|
185
|
+
| `DataViews.Tree` | a hierarchy, optionally beside a pane | `nodes` — it never builds one | [`tree-custom`](./examples/tree-custom.md) |
|
|
186
|
+
|
|
187
|
+
Each takes `id`, `label` and `icon` to control how it appears in the switcher, so the same view can
|
|
188
|
+
be registered twice with different data. Full props are under
|
|
189
|
+
[API Reference](#api-reference) — one heading per part.
|
|
190
|
+
|
|
191
|
+
### The tree's pane
|
|
192
|
+
|
|
193
|
+
Pick a node and the pane beside it lists what that node holds. Its header names the selected node
|
|
194
|
+
and counts its records, and it stays up before you have picked anything, so the tabs are always
|
|
195
|
+
reachable.
|
|
196
|
+
|
|
197
|
+
**The pane's tabs are children**, the same bargain the component's own views strike: a tab exists
|
|
198
|
+
because you rendered it, and the switch shows exactly what you passed. Pass one and there is no
|
|
199
|
+
switch at all — a switch with a single option is a label. Pass **none** and there is no pane
|
|
200
|
+
either; the tree is then a hierarchy and nothing else, and the rail takes the whole width:
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
<DataViews.Tree nodes={nodes} labelPath="status" /> // a rail, no pane
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
<DataViews.Tree nodes={nodes} labelPath="status">
|
|
208
|
+
<DataViews.Tree.Table selectable onRowClick={open} renderCell={cell} />
|
|
209
|
+
<DataViews.Tree.Cards renderCard={card} />
|
|
210
|
+
<DataViews.Tree.Tab value="timeline" label="Timeline" icon={<Clock />}>
|
|
211
|
+
<Timeline />
|
|
212
|
+
</DataViews.Tree.Tab>
|
|
213
|
+
</DataViews.Tree>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
| Part | What it is |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| `DataViews.Tree.Table` | the **real `DataViews.Table`** over the node's rows — `selectable`, `onRowClick`, `renderCell`, `onRowMove`, `onAddRow`, virtualization past 300 rows |
|
|
219
|
+
| `DataViews.Tree.Cards` | the board's `DataViewCard`; `renderCard` replaces it, `className` replaces the grid |
|
|
220
|
+
| `DataViews.Tree.Tab` | a mode of your own — its `children` are the pane while it is selected |
|
|
221
|
+
|
|
222
|
+
All three take `value`, `label` and `icon` to name themselves in the switch, exactly as the
|
|
223
|
+
component's views take `id`, `label` and `icon`. The two built-in ones fill those in, so you pass
|
|
224
|
+
them only to rename a tab or to register the same view twice.
|
|
225
|
+
|
|
226
|
+
**A tab sees the node's rows.** Everything inside the pane runs in a data scope whose `rows` are
|
|
227
|
+
the ones the pane lists, so a tab of your own reads them the same way any other part does — no
|
|
228
|
+
props to thread:
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
function Timeline() {
|
|
232
|
+
const { rows } = useDataViewsData(); // the selected node's rows, already narrowed by paneRows
|
|
233
|
+
return <ol>{rows.map((row) => <li key={String(row.id)}>{String(row.createdAt)}</li>)}</ol>;
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Which rows.** By default a node shows its **descendants** — a branch lists what is under it, a
|
|
238
|
+
leaf lists itself. A synthetic grouping branch has no meaningful row of its own, which is why
|
|
239
|
+
descendants are the default. Override with `paneRows` when the tree and the pane hold different
|
|
240
|
+
things — a tree of categories whose pane must list that category's items:
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
<DataViews.Tree nodes={categories} paneRows={(node) => itemsUnder(node.id)} />
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`paneRows` is also where sorting belongs if you want the pane self-contained. The pane's table
|
|
247
|
+
headers sort by writing to the **query**, like every other sort in this component — so a pane sort
|
|
248
|
+
re-fetches the dataset and only reorders the pane once re-sorted nodes come back.
|
|
249
|
+
|
|
250
|
+
**Mode.** `defaultPaneMode` seeds it, `onPaneModeChange` reports every switch, `paneMode` takes it
|
|
251
|
+
over entirely. The value is a tab's `value` — `"table"`, `"cards"`, or your own. Seed and persist
|
|
252
|
+
is the round-trip:
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
<DataViews.Tree
|
|
256
|
+
nodes={nodes}
|
|
257
|
+
defaultPaneMode={loadPref() ?? "table"}
|
|
258
|
+
onPaneModeChange={savePref}
|
|
259
|
+
/>
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**The header** takes your markup through `paneActions` — an Add button, a menu, a count of your
|
|
263
|
+
own. It sits between the record count and the switch.
|
|
264
|
+
|
|
265
|
+
**And when none of it fits**, any child that is *not* one of those three tabs **is** the pane:
|
|
266
|
+
header, switch and all. That is the escape hatch, and what every tree written before these tabs
|
|
267
|
+
existed passes.
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
<DataViews.Tree nodes={nodes}>
|
|
271
|
+
<MyOwnPane />
|
|
272
|
+
</DataViews.Tree>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
A full working example of every seam above — `renderNode`, `paneRows`, a custom cell, a custom
|
|
276
|
+
card, a custom tab, `paneActions` and a whole-pane override — is `app/data-views/tree-custom`.
|
|
277
|
+
|
|
278
|
+
**`DataViews.Detail`** is the ready-made detail pane for the inbox and tree. Drop it in as
|
|
279
|
+
`children` and it renders every visible field of whatever row is open, as a `<dl>`, painted through
|
|
280
|
+
the same `Cell` the views use. It renders **nothing** when no row is open — including when the open
|
|
281
|
+
id belongs to a node that is not a row (see [One caveat](#one-caveat)).
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
<DataViews.Inbox titlePath="subject" datePath="createdAt">
|
|
285
|
+
<DataViews.Detail />
|
|
286
|
+
</DataViews.Inbox>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Header, panel and filters
|
|
290
|
+
|
|
291
|
+
`DataViews.Header` holds `ViewSwitch`, `Search`, `Actions` and `PanelToggle`. The bar is always
|
|
292
|
+
dark, scoped with `data-theme="dark"`, so it reads correctly whatever theme the host app runs in.
|
|
293
|
+
|
|
294
|
+
`DataViews.Panel` is the 260px settings rail: `Panel.Tab` for each tab, with `Panel.SavedViews`,
|
|
295
|
+
`Panel.Columns` (show/hide and drag-reorder) and `Panel.Sort` inside.
|
|
296
|
+
|
|
297
|
+
`DataViews.Filters` takes **FormBuilder fields as children** — not a config array describing
|
|
298
|
+
fields, the fields themselves. The `<FormBuilder>` lives inside `Filters`; you supply only its
|
|
299
|
+
fields, and what each one *means* comes from the field, never from the rows.
|
|
300
|
+
|
|
301
|
+
Five field kinds become filters. Anything else renders but filters nothing — a checkbox is not a
|
|
302
|
+
query, and a file is not a value you can filter by.
|
|
303
|
+
|
|
304
|
+
**Filters are not derived from `fields`.** There is no `filterable`, `filterMode`, `filterVariant`,
|
|
305
|
+
`filterOptions` or `filterLabel` — a filter exists because you wrote the control, and what it
|
|
306
|
+
*means* comes from that field's `FieldKind`. Hiding a column (`visible: false`, `type: "hidden"`)
|
|
307
|
+
has no effect on filters at all; they are independent surfaces.
|
|
308
|
+
|
|
309
|
+
### Choosing a control
|
|
310
|
+
|
|
311
|
+
Two questions pick it, and **the first is about the data, not the UI**:
|
|
312
|
+
|
|
313
|
+
1. **Can the option set grow?** Not how many values it has today — whether it can scale. A set the
|
|
314
|
+
system's own model fixes (four order statuses) will never grow without a release. A set the org
|
|
315
|
+
configures, or a workflow editor can extend, is **dynamic** — and a dynamic set with four values
|
|
316
|
+
today can have sixteen next quarter.
|
|
317
|
+
2. **Can the user pick more than one?**
|
|
318
|
+
|
|
319
|
+
| | **Fixed** — bounded by the model | **Dynamic** — org-configurable |
|
|
320
|
+
| --- | --- | --- |
|
|
321
|
+
| **Multi-pick** | `CheckboxGroup` | `MultiSelect` / `Tags` |
|
|
322
|
+
| **Single-pick** | `RadioList` | `SearchableSelect` |
|
|
323
|
+
|
|
324
|
+
A closed lifecycle (`Draft · Active · Discontinued`) is fixed. Brand, Owner, Vendor, Assignee, Tags
|
|
325
|
+
and Category are dynamic unconditionally. A *status* can be either — if an org can extend it
|
|
326
|
+
through an approval chain, treat it as dynamic, because sixteen stacked checkboxes is not a control.
|
|
327
|
+
|
|
328
|
+
### The section types
|
|
329
|
+
|
|
330
|
+
| Control | Use when | Renders | Lands in `FilterState` as |
|
|
331
|
+
| --- | --- | --- | --- |
|
|
332
|
+
| `FormBuilder.CheckboxGroup` | fixed set, multi-pick | checkbox list | `string[]` |
|
|
333
|
+
| `FormBuilder.RadioList` | fixed set, single-pick | radio list | one-element `string[]` |
|
|
334
|
+
| `FormBuilder.SearchableSelect` | dynamic set, single-pick | searchable combobox | one-element `string[]` |
|
|
335
|
+
| `FormBuilder.MultiSelect` · `FormBuilder.Tags` | dynamic set, multi-pick | **`BadgeField`** — search + chips | `string[]` |
|
|
336
|
+
| `FormBuilder.Slider range` | numeric | range slider | `{ kind: "number", min, max }` |
|
|
337
|
+
| `FormBuilder.DateRange` | date | date pair | `{ kind: "date", from, to }` |
|
|
338
|
+
| `FormBuilder.Text` | free text | text input | one-element `string[]` |
|
|
339
|
+
| ~~toggle / switch~~ | boolean | — | **not supported** |
|
|
340
|
+
|
|
341
|
+
`MultiSelect` and `Tags` are the **same field**: both render `BadgeField`, so the searchable
|
|
342
|
+
multi-select case needs nothing extra.
|
|
343
|
+
|
|
344
|
+
**Booleans do not filter yet.** `FormBuilder.SwitchBox` and `FormBuilder.Checkbox` are stamped
|
|
345
|
+
`boolean`, but the filter map (`filters/children.tsx`) recognises only `text · choice ·
|
|
346
|
+
multiChoice · date · slider`. A switch placed in `Filters` renders and writes nothing. Until that
|
|
347
|
+
kind is added, express an on/off narrowing with `Filters.Custom`, which can write any shape you
|
|
348
|
+
like to one path.
|
|
349
|
+
|
|
350
|
+
Every control here is live on `app/data-views/filters/page.tsx`:
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
<DataViews.Filters>
|
|
354
|
+
{/* fixed set, multi-pick — the four statuses are fixed by the model */}
|
|
355
|
+
<FormBuilder.CheckboxGroup name="status" label="Status" options={STATUS_OPTIONS} />
|
|
356
|
+
|
|
357
|
+
{/* fixed set, single-pick — High/Medium/Low are mutually exclusive */}
|
|
358
|
+
<FormBuilder.RadioList name="priority" label="Priority" options={PRIORITY_OPTIONS} />
|
|
359
|
+
|
|
360
|
+
{/* dynamic set, single-pick — customers are data-fed, one per record */}
|
|
361
|
+
<FormBuilder.SearchableSelect name="customer.name" label="Customer" options={CUSTOMER_OPTIONS} />
|
|
362
|
+
|
|
363
|
+
{/* dynamic set, multi-pick — renders BadgeField: search *and* several values, as chips */}
|
|
364
|
+
<FormBuilder.MultiSelect name="brand.name" label="Brand" options={BRAND_OPTIONS} />
|
|
365
|
+
|
|
366
|
+
<FormBuilder.Slider name="total" label="Total" range min={0} max={15000} step={100} />
|
|
367
|
+
<FormBuilder.DateRange name="createdAt" label="Created" />
|
|
368
|
+
</DataViews.Filters>
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**One rule you cannot guess from the markup.** react-hook-form reads `.` as object nesting, so a
|
|
372
|
+
filter on `customer.name` is registered as `customer__name` — you write the real path and `Filters`
|
|
373
|
+
escapes it, but anything calling `setValue` yourself must use the escaped name. (The other, that a
|
|
374
|
+
control at its neutral position emits no key at all, is under
|
|
375
|
+
[Questions this design gets asked](#questions-this-design-gets-asked).)
|
|
376
|
+
|
|
377
|
+
### Presets, custom filters and the summary
|
|
378
|
+
|
|
379
|
+
```tsx
|
|
380
|
+
<DataViews.Filters>
|
|
381
|
+
<FormBuilder.Slider name="total" label="Total" range min={0} max={15000} />
|
|
382
|
+
{/* Quick-set chips for one field. Number presets take min/max, date presets from/to. */}
|
|
383
|
+
<DataViews.Filters.Presets
|
|
384
|
+
for="total"
|
|
385
|
+
items={[
|
|
386
|
+
{ label: "Under $500", max: 500 },
|
|
387
|
+
{ label: "$5k+", min: 5000 },
|
|
388
|
+
]}
|
|
389
|
+
/>
|
|
390
|
+
|
|
391
|
+
{/* The escape hatch: any control you like, driving one filter path yourself. */}
|
|
392
|
+
<DataViews.Filters.Custom
|
|
393
|
+
path="items"
|
|
394
|
+
label="Item count"
|
|
395
|
+
render={({ value, setValue }) => (
|
|
396
|
+
<ItemsPicker value={value} onChange={(next) => setValue(next)} />
|
|
397
|
+
)}
|
|
398
|
+
/>
|
|
399
|
+
</DataViews.Filters>
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`Filters.Summary` paints whatever is active as removable chips. It reads the same context, so it
|
|
403
|
+
works anywhere — most usefully **outside** the rail, where it tells the user what is filtering the
|
|
404
|
+
rows they are looking at:
|
|
405
|
+
|
|
406
|
+
```tsx
|
|
407
|
+
<DataViews.Filters.Summary className="px-4 py-2" />
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Questions this design gets asked
|
|
411
|
+
|
|
412
|
+
| Question | Answer |
|
|
413
|
+
| --- | --- |
|
|
414
|
+
| Is there an in-view filter panel *and* a Filters tab — which is canonical? | **One surface.** `DataViews.Filters` is a single component. Render it inside a `Panel.Tab` or as a standalone bar; author against the component, not against a tab. |
|
|
415
|
+
| What orders the sections? | **The order you write the children.** There is no `order` prop for filters. |
|
|
416
|
+
| Does the applied-count badge count constrained *fields* or selected *values*? | **There is no count badge.** `PanelToggle` carries none. `Filters.Summary` is the equivalent, and it renders **one chip per constrained field** — Status with three values selected is one chip. |
|
|
417
|
+
| Do `BadgeField` chip colours and `FieldConfig.variants` share a token set? | **They never meet.** Chips come from the field's own `options`; `variants` (`BadgeVariant`) styles `enum-badge` **columns**. Filters and columns are independent. |
|
|
418
|
+
|
|
419
|
+
And the behaviours worth stating because they are easy to assume wrongly:
|
|
420
|
+
|
|
421
|
+
- **Empty is unconstrained.** A control at its neutral position emits **no key** — a slider dragged
|
|
422
|
+
back to exactly `[min, max]` removes its filter rather than sending "everything". Filters never
|
|
423
|
+
narrow to zero rows because a section was touched and cleared.
|
|
424
|
+
- **Filter state is one object.** `onQueryChange` receives the whole next query, not a per-field
|
|
425
|
+
delta.
|
|
426
|
+
- **Filters survive a view switch.** Table, board, inbox and tree share one query.
|
|
427
|
+
- **Persistence is yours.** The component holds the query only as long as it is mounted.
|
|
428
|
+
- **How constraints combine is *your* matcher's business,** not the component's — it reports what
|
|
429
|
+
the user asked for and nothing more. The reference endpoint in `app/api/_lib/query.ts` intersects
|
|
430
|
+
across fields (AND) and unions within one field (OR), which is what most callers want.
|
|
431
|
+
|
|
432
|
+
### Filters outside the settings rail
|
|
433
|
+
|
|
434
|
+
Nothing requires `Filters` to live in a `Panel.Tab`. Rendered as a plain child it becomes a bar
|
|
435
|
+
above the views, inside the light surface — which is what you want when filtering is the main task
|
|
436
|
+
rather than a setting:
|
|
437
|
+
|
|
438
|
+
```tsx
|
|
439
|
+
<DataViews rows={rows} fields={fields} total={total} onQueryChange={setQuery}>
|
|
440
|
+
<DataViews.Header title="Orders">
|
|
441
|
+
<DataViews.ViewSwitch />
|
|
442
|
+
</DataViews.Header>
|
|
443
|
+
|
|
444
|
+
{/* A bar, not a tab. `title` and the bottom border are on by default here. */}
|
|
445
|
+
<DataViews.Filters title="Filters">
|
|
446
|
+
<FormBuilder.MultiSelect name="status" label="Status" options={STATUS} />
|
|
447
|
+
<FormBuilder.DateRange name="createdAt" label="Created" />
|
|
448
|
+
</DataViews.Filters>
|
|
449
|
+
|
|
450
|
+
<DataViews.Table />
|
|
451
|
+
</DataViews>
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Inside a tab you normally turn that chrome off — `<DataViews.Filters title={null}
|
|
455
|
+
className="border-b-0 p-0">` — because the tab already provides it.
|
|
456
|
+
|
|
457
|
+
## API Reference
|
|
458
|
+
|
|
459
|
+
### `<DataViews>` (root)
|
|
460
|
+
|
|
461
|
+
| Prop | Type | Default | Description |
|
|
462
|
+
| --- | --- | --- | --- |
|
|
463
|
+
| `rows` * | `readonly Row[]` | — | The rows to paint, already queried, in the order they should appear. |
|
|
464
|
+
| `fields` * | `readonly FieldConfig[]` | — | How to paint each field. Order here is the default column order. |
|
|
465
|
+
| `children` * | `ReactNode` | — | The parts to render — the header, views, panel, filters. |
|
|
466
|
+
| `getRowId` | `(row, index) => string` | `row.id ?? index` | Stable identity. Selection, drag and the active row all key off it. |
|
|
467
|
+
| `total` | `number` | `0` | Total matching rows on the server. `hasMore` is derived from `rows.length < total`. |
|
|
468
|
+
| `loading` | `boolean` | `false` | First load. Each view paints its own skeleton. |
|
|
469
|
+
| `onLoadMore` | `() => void` | — | A view reached its end. Fetch the next page and **append** it to `rows`. |
|
|
470
|
+
| `loadingMore` | `boolean` | `false` | That next page is in flight — distinct from `loading`. |
|
|
471
|
+
| `query` | `DataViewsQuery` | — | Controlled query. Omit to let the component hold it. |
|
|
472
|
+
| `onQueryChange` | `(query) => void` | — | Search, filters, sort, page or page size changed. Go and fetch. |
|
|
473
|
+
| `defaultQuery` | `Partial<DataViewsQuery>` | — | Seed for the uncontrolled query — e.g. a starting `pageSize`. |
|
|
474
|
+
| `defaultView` | `string` | first registered | Which view opens. |
|
|
475
|
+
| `defaultPanelOpen` | `boolean` | `false` | Start with the settings rail open. |
|
|
476
|
+
| `onViewChange` | `(view: string) => void` | — | Told which view is showing; the component still owns it. |
|
|
477
|
+
| `onSelectionChange` | `(ids: readonly string[]) => void` | — | Told what is selected. |
|
|
478
|
+
| `onActiveIdChange` | `(id: string \| null) => void` | — | Told which row is open. |
|
|
479
|
+
| `theme` | `Themes` | — | `data-theme` for the **content**. The chrome stays dark regardless. |
|
|
480
|
+
| `className` | `string` | — | |
|
|
481
|
+
|
|
482
|
+
`page` resets to 1 internally whenever the search, filters, sort or page size change — a new
|
|
483
|
+
result set has no page 4 to stay on.
|
|
484
|
+
|
|
485
|
+
Every part below has its own heading, so you can ask for one on its own — with the MCP server,
|
|
486
|
+
`get-component-api DataViews part="Board"`. Columns are the same throughout:
|
|
487
|
+
**Prop · Type · Default · Required · Notes**.
|
|
488
|
+
|
|
489
|
+
### DataViews.Header
|
|
490
|
+
|
|
491
|
+
The dark bar across the top. Its children are the four parts below, in whatever order you write
|
|
492
|
+
them.
|
|
493
|
+
|
|
494
|
+
| Prop | Type | Default | Required | Notes |
|
|
495
|
+
| --- | --- | --- | --- | --- |
|
|
496
|
+
| `title` | `ReactNode` | — | no | The uppercase title pill. |
|
|
497
|
+
| `children` | `ReactNode` | — | no | `ViewSwitch`, `Search`, `Actions`, `PanelToggle`. |
|
|
498
|
+
| `className` | `string` | — | no | |
|
|
499
|
+
|
|
500
|
+
### DataViews.ViewSwitch
|
|
501
|
+
|
|
502
|
+
| Prop | Type | Default | Required | Notes |
|
|
503
|
+
| --- | --- | --- | --- | --- |
|
|
504
|
+
| `className` | `string` | — | no | |
|
|
505
|
+
|
|
506
|
+
Renders a spacer instead of a switcher when fewer than two views are registered — one option is a
|
|
507
|
+
label, not a choice.
|
|
508
|
+
|
|
509
|
+
### DataViews.Search
|
|
510
|
+
|
|
511
|
+
| Prop | Type | Default | Required | Notes |
|
|
512
|
+
| --- | --- | --- | --- | --- |
|
|
513
|
+
| `placeholder` | `string` | `"Search..."` | no | |
|
|
514
|
+
| `className` | `string` | — | no | |
|
|
515
|
+
|
|
516
|
+
An icon button that expands into a field. It writes `search` into the query and matches nothing
|
|
517
|
+
itself; it collapses on an outside click only while empty, so an active term is never thrown away.
|
|
518
|
+
|
|
519
|
+
### DataViews.Actions
|
|
520
|
+
|
|
521
|
+
| Prop | Type | Default | Required | Notes |
|
|
522
|
+
| --- | --- | --- | --- | --- |
|
|
523
|
+
| `children` | `ReactNode` | — | no | Your buttons, pushed to the end of the bar. |
|
|
524
|
+
| `className` | `string` | — | no | |
|
|
525
|
+
|
|
526
|
+
### DataViews.PanelToggle
|
|
527
|
+
|
|
528
|
+
| Prop | Type | Default | Required | Notes |
|
|
529
|
+
| --- | --- | --- | --- | --- |
|
|
530
|
+
| `children` | `ReactNode` | `"Filter & Config."` | no | The label beside the gear. |
|
|
531
|
+
| `className` | `string` | — | no | |
|
|
532
|
+
|
|
533
|
+
Renders `null` while the rail is open — the rail carries its own close button.
|
|
534
|
+
|
|
535
|
+
### DataViews.Table
|
|
536
|
+
|
|
537
|
+
Rows and columns. Virtualizes past 300 rows.
|
|
538
|
+
Example: [`views`](./examples/views.md).
|
|
539
|
+
|
|
540
|
+
| Prop | Type | Default | Required | Notes |
|
|
541
|
+
| --- | --- | --- | --- | --- |
|
|
542
|
+
| `selectable` | `boolean` | `false` | no | Per-row checkboxes and select-all. |
|
|
543
|
+
| `onRowClick` | `(row: Row, id: string) => void` | — | no | Also makes rows keyboard-reachable. |
|
|
544
|
+
| `onRowMove` | `(intent: MoveIntent) => void` | — | no | Row reordering. Adds a grip column; moves nothing itself. |
|
|
545
|
+
| `onAddRow` | `() => void` | — | no | Shows the `+ Add New` row at the foot of the table. |
|
|
546
|
+
| `addRowLabel` | `string` | `"Add New"` | no | |
|
|
547
|
+
| `renderCell` | `(args: RowRenderArgs & { field: FieldConfig }) => ReactNode` | — | no | Return `undefined` to fall through to the default cell. |
|
|
548
|
+
| `id` · `label` · `icon` · `className` | `ViewBaseProps` | `"table"` · `"List"` | no | How it appears in the switcher. |
|
|
549
|
+
|
|
550
|
+
### DataViews.Board
|
|
551
|
+
|
|
552
|
+
A kanban board. **You** build the columns — the board never groups rows itself.
|
|
553
|
+
Example: [`views`](./examples/views.md).
|
|
554
|
+
|
|
555
|
+
| Prop | Type | Default | Required | Notes |
|
|
556
|
+
| --- | --- | --- | --- | --- |
|
|
557
|
+
| `groups` | `readonly RowGroup[]` | — | **yes** | The columns, in order. |
|
|
558
|
+
| `titlePath` | `string` | first visible field | no | Which field is the card title. |
|
|
559
|
+
| `renderCard` | `(args: RowRenderArgs & { group: RowGroup; isActive: boolean; isDragging: boolean }) => ReactNode` | — | no | Replaces the card; the wrapper keeps the drag. |
|
|
560
|
+
| `onRowMove` | `(intent: MoveIntent) => void` | — | no | A card was dropped. `intent.to` is the group id, or `null` outside any column. |
|
|
561
|
+
| `onColumnAction` | `(groupId: string) => void` | — | no | The per-column action button in its header. |
|
|
562
|
+
| `id` · `label` · `icon` · `className` | `ViewBaseProps` | `"board"` · `"Board"` | no | |
|
|
563
|
+
|
|
564
|
+
### DataViews.Inbox
|
|
565
|
+
|
|
566
|
+
A master list beside a detail pane.
|
|
567
|
+
Example: [`inbox-routing`](./examples/inbox-routing.md), which drives the
|
|
568
|
+
pane from the URL.
|
|
569
|
+
|
|
570
|
+
| Prop | Type | Default | Required | Notes |
|
|
571
|
+
| --- | --- | --- | --- | --- |
|
|
572
|
+
| `children` | `ReactNode` | — | no | The detail pane — often `<DataViews.Detail/>`. |
|
|
573
|
+
| `renderItem` | `(args: RowRenderArgs & { isActive: boolean }) => ReactNode` | — | no | Replaces the list item. |
|
|
574
|
+
| `titlePath` | `string` | — | no | Which field leads the item. |
|
|
575
|
+
| `datePath` | `string` | first `date`/`date-format` field | no | Which field shows as the date chip. |
|
|
576
|
+
| `itemHref` | `(row: Row, id: string) => string` | — | no | Makes items links rather than buttons — that is what gives them a back button and a shareable URL. |
|
|
577
|
+
| `linkComponent` | `React.ElementType` | `"a"` | no | Your router's link — e.g. `next/link`. |
|
|
578
|
+
| `placeholder` | `ReactNode` | built-in empty pane | no | Shown while nothing is open. |
|
|
579
|
+
| `id` · `label` · `icon` · `className` | `ViewBaseProps` | `"inbox"` · `"Inbox"` | no | |
|
|
580
|
+
|
|
581
|
+
### DataViews.Tree
|
|
582
|
+
|
|
583
|
+
A hierarchy beside a pane. **You** build `nodes` — which field is the parent key, whether orphans
|
|
584
|
+
become roots, how cycles are handled are decisions only you can make correctly.
|
|
585
|
+
Example: [`tree-custom`](./examples/tree-custom.md).
|
|
586
|
+
|
|
587
|
+
| Prop | Type | Default | Required | Notes |
|
|
588
|
+
| --- | --- | --- | --- | --- |
|
|
589
|
+
| `nodes` | `readonly TreeNode[]` | — | **yes** | The hierarchy. |
|
|
590
|
+
| `labelPath` | `string` | first visible field | no | Which field labels a node. |
|
|
591
|
+
| `renderNode` | `(args: { node: TreeNode; row: Row; fields: readonly FieldConfig[] }) => { name?: string; icon?: ReactNode; meta?: ReactNode }` | — | no | Returns parts, not markup — `TreeFolder` owns the row. |
|
|
592
|
+
| `expanded` | `readonly string[]` | root nodes that have children | no | Controlled expansion. |
|
|
593
|
+
| `onExpandedChange` | `(ids: readonly string[]) => void` | — | no | |
|
|
594
|
+
| `onNodeMove` | `(intent: MoveIntent) => void` | — | no | A node was dropped into a new parent. Passing it is what turns drag on. |
|
|
595
|
+
| `paneMode` | `TreePaneMode` | — | no | Controls the pane's tab. Omit to let the view hold it. |
|
|
596
|
+
| `defaultPaneMode` | `TreePaneMode` | **the first tab's `value`** | no | Not a hardcoded `"table"` — a pane whose only tab is yours opens on it. |
|
|
597
|
+
| `onPaneModeChange` | `(mode: TreePaneMode) => void` | — | no | The user switched. Persist it and seed `defaultPaneMode` back. |
|
|
598
|
+
| `paneRows` | `(node: TreeNode) => readonly Row[]` | the node's descendants; a leaf yields itself | no | Which rows the pane lists. |
|
|
599
|
+
| `paneActions` | `ReactNode` | — | no | Your markup in the pane's header, before the tab switch. |
|
|
600
|
+
| `children` | `ReactNode` | — | no | The pane's tabs. **None means no pane**; anything that is not a tab **is** the pane. |
|
|
601
|
+
| `id` · `label` · `icon` · `className` | `ViewBaseProps` | `"tree"` · `"Tree"` | no | |
|
|
602
|
+
|
|
603
|
+
`TreePaneMode` is `"table" | "cards" | (string & {})` — any string, because a `Tree.Tab` of yours
|
|
604
|
+
names its own mode.
|
|
605
|
+
|
|
606
|
+
### DataViews.Tree.Table
|
|
607
|
+
|
|
608
|
+
The pane as a table. It **is** `DataViews.Table`, rendered over the selected node's rows, so it
|
|
609
|
+
keeps sortable headers, selection, `renderCell`, the grip, `+ Add New` and virtualization.
|
|
610
|
+
|
|
611
|
+
| Prop | Type | Default | Required | Notes |
|
|
612
|
+
| --- | --- | --- | --- | --- |
|
|
613
|
+
| `value` | `string` | `"table"` | no | What `paneMode` becomes while this tab shows. |
|
|
614
|
+
| `label` | `string` | `"List"` | no | |
|
|
615
|
+
| `icon` | `ReactNode` | `<Table2/>` | no | |
|
|
616
|
+
| *…all of `DataViews.Table`'s* | | | no | `selectable`, `onRowClick`, `onRowMove`, `onAddRow`, `addRowLabel`, `renderCell`. |
|
|
617
|
+
|
|
618
|
+
**Gotcha.** Its headers sort by writing to the **query**, like every other sort here — so a pane
|
|
619
|
+
sort re-fetches the dataset and only reorders the pane once re-sorted nodes come back. Sort inside
|
|
620
|
+
`paneRows` if you want the pane self-contained. It takes no `className` (that is a `ViewBaseProps`
|
|
621
|
+
key, omitted).
|
|
622
|
+
|
|
623
|
+
### DataViews.Tree.Cards
|
|
624
|
+
|
|
625
|
+
The pane as a grid of cards — the same `DataViewCard` the board paints.
|
|
626
|
+
|
|
627
|
+
| Prop | Type | Default | Required | Notes |
|
|
628
|
+
| --- | --- | --- | --- | --- |
|
|
629
|
+
| `value` | `string` | `"cards"` | no | |
|
|
630
|
+
| `label` | `string` | `"Cards"` | no | |
|
|
631
|
+
| `icon` | `ReactNode` | `<LayoutGrid/>` | no | |
|
|
632
|
+
| `renderCard` | `(args: RowRenderArgs) => ReactNode` | — | no | Replaces `DataViewCard` outright. |
|
|
633
|
+
| `className` | `string` | `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4` | no | **Replaces** the grid classes; it is not merged. |
|
|
634
|
+
|
|
635
|
+
### DataViews.Tree.Tab
|
|
636
|
+
|
|
637
|
+
A mode of your own. Its `children` are the pane while it is selected, and nothing while it is not.
|
|
638
|
+
|
|
639
|
+
| Prop | Type | Default | Required | Notes |
|
|
640
|
+
| --- | --- | --- | --- | --- |
|
|
641
|
+
| `value` | `string` | — | **yes** | A tab only you know about has no default name. |
|
|
642
|
+
| `label` | `string` | — | **yes** | |
|
|
643
|
+
| `icon` | `ReactNode` | — | no | |
|
|
644
|
+
| `children` | `ReactNode` | — | no | Reads the node's rows from `useDataViewsData()` — the pane scopes them. |
|
|
645
|
+
|
|
646
|
+
### DataViews.Detail
|
|
647
|
+
|
|
648
|
+
The ready-made detail pane for the inbox and tree: every visible field of the open row, as a `<dl>`,
|
|
649
|
+
painted through `Cell`.
|
|
650
|
+
|
|
651
|
+
| Prop | Type | Default | Required | Notes |
|
|
652
|
+
| --- | --- | --- | --- | --- |
|
|
653
|
+
| `className` | `string` | — | no | |
|
|
654
|
+
|
|
655
|
+
Renders **nothing** when no row is open — including when the open id belongs to a node that is not
|
|
656
|
+
a row (see [One caveat](#one-caveat)).
|
|
657
|
+
|
|
658
|
+
### DataViews.Panel
|
|
659
|
+
|
|
660
|
+
The 260px settings rail, opened by `PanelToggle`. Always dark.
|
|
661
|
+
Example: [`panel`](./examples/panel.md).
|
|
662
|
+
|
|
663
|
+
| Prop | Type | Default | Required | Notes |
|
|
664
|
+
| --- | --- | --- | --- | --- |
|
|
665
|
+
| `children` | `ReactNode` | — | no | `Panel.Tab`s. |
|
|
666
|
+
| `defaultTab` | `string` | first rendered tab | no | |
|
|
667
|
+
| `title` | `ReactNode` | the single tab's label | no | Shown only when there is one tab or none. |
|
|
668
|
+
| `className` | `string` | — | no | |
|
|
669
|
+
|
|
670
|
+
### DataViews.Panel.Tab
|
|
671
|
+
|
|
672
|
+
| Prop | Type | Default | Required | Notes |
|
|
673
|
+
| --- | --- | --- | --- | --- |
|
|
674
|
+
| `value` | `string` | — | **yes** | |
|
|
675
|
+
| `label` | `string` | — | **yes** | |
|
|
676
|
+
| `icon` | `ReactNode` | — | no | |
|
|
677
|
+
| `children` | `ReactNode` | — | no | Rendered only while this tab is open. |
|
|
678
|
+
|
|
679
|
+
The strip disappears when there is only one tab.
|
|
680
|
+
|
|
681
|
+
### DataViews.Panel.Section
|
|
682
|
+
|
|
683
|
+
A titled, collapsible group inside a tab — the same `ConclusionHeader` `FormSummary` uses.
|
|
684
|
+
|
|
685
|
+
| Prop | Type | Default | Required | Notes |
|
|
686
|
+
| --- | --- | --- | --- | --- |
|
|
687
|
+
| `title` | `ReactNode` | — | no | Without one there is no header and no fold. |
|
|
688
|
+
| `description` | `ReactNode` | — | no | Sits with the header, outside the fold, so it still reads when shut. |
|
|
689
|
+
| `collapsible` | `boolean` | `true` | no | |
|
|
690
|
+
| `defaultOpen` | `boolean` | `true` | no | Nothing starts folded. |
|
|
691
|
+
| `children` · `className` | | — | no | |
|
|
692
|
+
|
|
693
|
+
A collapsed body keeps its controls in the DOM but marks them `inert`, so focus cannot reach them.
|
|
694
|
+
|
|
695
|
+
### DataViews.Panel.Columns
|
|
696
|
+
|
|
697
|
+
Show/hide and drag-reorder the columns. Takes no `value`/`onValueChange` — the column list is the
|
|
698
|
+
root's, reached through `useDataViewsView()`.
|
|
699
|
+
|
|
700
|
+
| Prop | Type | Default | Required | Notes |
|
|
701
|
+
| --- | --- | --- | --- | --- |
|
|
702
|
+
| `title` | `ReactNode` | `"Table Columns"` | no | |
|
|
703
|
+
| `description` | `ReactNode` | `"Show or hide columns in table view"` | no | |
|
|
704
|
+
| `className` | `string` | — | no | |
|
|
705
|
+
|
|
706
|
+
Hiding a column here changes `visibleFields` everywhere — it also retitles the board's cards.
|
|
707
|
+
|
|
708
|
+
### DataViews.Panel.Sort
|
|
709
|
+
|
|
710
|
+
The default-sort picker, for views that have no column headers. Writes into the query.
|
|
711
|
+
|
|
712
|
+
| Prop | Type | Default | Required | Notes |
|
|
713
|
+
| --- | --- | --- | --- | --- |
|
|
714
|
+
| `title` | `ReactNode` | `"Default Sort"` | no | |
|
|
715
|
+
| `className` | `string` | — | no | |
|
|
716
|
+
|
|
717
|
+
### DataViews.Panel.SavedViews
|
|
718
|
+
|
|
719
|
+
| Prop | Type | Default | Required | Notes |
|
|
720
|
+
| --- | --- | --- | --- | --- |
|
|
721
|
+
| `views` | `readonly SavedView[]` | `[]` | no | The list you persisted. |
|
|
722
|
+
| `onValueChange` | `(id: string) => void` | — | no | A saved view was picked. |
|
|
723
|
+
| `onSave` | `(snapshot: SavedViewSnapshot) => void` | — | no | The Save button appears only when you pass this. |
|
|
724
|
+
| `saveLabel` | `ReactNode` | `"Save a New View"` | no | |
|
|
725
|
+
| `title` | `ReactNode` | `"Saved View"` | no | |
|
|
726
|
+
| `className` | `string` | — | no | |
|
|
727
|
+
|
|
728
|
+
Saving is yours — a view outlives the component. Restoring is not: hand the snapshot back and
|
|
729
|
+
selecting it puts filters, sort and columns back internally.
|
|
730
|
+
|
|
731
|
+
### DataViews.Filters
|
|
732
|
+
|
|
733
|
+
The filter controls, written as a form. Takes **no** `value`/`onValueChange`: it reads and writes
|
|
734
|
+
the root's query through `useDataViewsFilters()`, and what leaves the component is `onQueryChange`.
|
|
735
|
+
Example: [`filters`](./examples/filters.md).
|
|
736
|
+
|
|
737
|
+
| Prop | Type | Default | Required | Notes |
|
|
738
|
+
| --- | --- | --- | --- | --- |
|
|
739
|
+
| `children` | `ReactNode` | — | no | **FormBuilder fields.** The `<FormBuilder>` is inside. |
|
|
740
|
+
| `title` | `ReactNode` | `"Filters"` | no | Pass `null` to drop the header row. |
|
|
741
|
+
| `description` | `ReactNode` | — | no | A small line under the title, above the fields. |
|
|
742
|
+
| `clearLabel` | `ReactNode` | `"Clear"` | no | The Clear button appears only while a filter is set. |
|
|
743
|
+
| `collapsible` | `boolean` | `true` | no | |
|
|
744
|
+
| `defaultOpen` | `boolean` | `true` | no | |
|
|
745
|
+
| `className` | `string` | — | no | |
|
|
746
|
+
|
|
747
|
+
### DataViews.Filters.Presets
|
|
748
|
+
|
|
749
|
+
Quick-set chips for one numeric or date field.
|
|
750
|
+
|
|
751
|
+
| Prop | Type | Default | Required | Notes |
|
|
752
|
+
| --- | --- | --- | --- | --- |
|
|
753
|
+
| `for` | `string` | — | **yes** | The field's path. Renders `null` if no field there. |
|
|
754
|
+
| `items` | `readonly Preset[]` | — | **yes** | `{ label, min?, max? }` or `{ label, from?, to? }`. |
|
|
755
|
+
| `className` | `string` | — | no | |
|
|
756
|
+
|
|
757
|
+
### DataViews.Filters.Custom
|
|
758
|
+
|
|
759
|
+
A filter no FormBuilder field covers.
|
|
760
|
+
|
|
761
|
+
| Prop | Type | Default | Required | Notes |
|
|
762
|
+
| --- | --- | --- | --- | --- |
|
|
763
|
+
| `path` | `string` | — | **yes** | The key it writes into `filters`. |
|
|
764
|
+
| `render` | `(args: { value: FilterValue \| undefined; setValue: (v: FilterValue \| undefined) => void }) => ReactNode` | — | **yes** | |
|
|
765
|
+
| `label` | `ReactNode` | derived from `path` | no | |
|
|
766
|
+
|
|
767
|
+
### DataViews.Filters.Summary
|
|
768
|
+
|
|
769
|
+
| Prop | Type | Default | Required | Notes |
|
|
770
|
+
| --- | --- | --- | --- | --- |
|
|
771
|
+
| `className` | `string` | — | no | |
|
|
772
|
+
|
|
773
|
+
Active filters — and the search term — as removable chips. Renders `null` when there are none.
|
|
774
|
+
|
|
775
|
+
### Cell
|
|
776
|
+
|
|
777
|
+
Paint one field of one row exactly as the views paint it.
|
|
778
|
+
|
|
779
|
+
| Prop | Type | Default | Required | Notes |
|
|
780
|
+
| --- | --- | --- | --- | --- |
|
|
781
|
+
| `field` | `FieldConfig` | — | **yes** | `field.render` wins over `field.type`. |
|
|
782
|
+
| `row` | `Row` | — | **yes** | |
|
|
783
|
+
| `className` | `string` | — | no | |
|
|
784
|
+
|
|
785
|
+
`type: "hidden"` renders `null`; a blank value renders `-`, except for `boolean` and
|
|
786
|
+
`progress-bar`, which have a meaningful zero.
|
|
787
|
+
|
|
788
|
+
### Hooks
|
|
789
|
+
|
|
790
|
+
All five throw if called outside `<DataViews>`.
|
|
791
|
+
|
|
792
|
+
| Hook | Returns |
|
|
793
|
+
| --- | --- |
|
|
794
|
+
| `useDataViewsData()` | `{ rows, fields, visibleFields, getRowId, loading, loadingMore, hasMore, onLoadMore? }` |
|
|
795
|
+
| `useDataViewsView()` | `{ view, setView, views, search, setSearch, sort, setSort, selection, setSelection, activeId, setActiveId, columns, setColumns }` |
|
|
796
|
+
| `useDataViewsPanel()` | `{ open, setOpen }` |
|
|
797
|
+
| `useDataViewsPanelTabs()` | `{ tab, setTab, tabs }` |
|
|
798
|
+
| `useDataViewsFilters()` | `{ filters, setFilters, filterFields }` |
|
|
799
|
+
| `useActiveRow()` | `Row \| null` — the row behind `activeId` |
|
|
800
|
+
|
|
801
|
+
Inside the tree's pane, `useDataViewsData().rows` is **the selected node's rows**, not the whole
|
|
802
|
+
set: the pane scopes the context. That is what lets a `Tree.Tab` of yours read them with no props.
|
|
803
|
+
|
|
804
|
+
### Utilities
|
|
805
|
+
|
|
806
|
+
| Export | Signature | What it is for |
|
|
807
|
+
| --- | --- | --- |
|
|
808
|
+
| `emptyQuery` | `(overrides?: Partial<DataViewsQuery>) => DataViewsQuery` | `{ search: "", filters: {}, sort: null, page: 1, pageSize: 10 }`. |
|
|
809
|
+
| `queryToParams` | `(query: DataViewsQuery) => URLSearchParams` | `search` · `filters` (JSON) · `sort` (`"path:direction"`) · `page` · `pageSize`. |
|
|
810
|
+
| `parseQuery` | `(url: URL) => DataViewsQuery` | The server half. Malformed filters → `{}`; `pageSize` clamped to 1–500. |
|
|
811
|
+
| `getByPath` | `(obj: unknown, path?: string) => unknown` | Reads `"customer.name"`. |
|
|
812
|
+
| `getString` | `(obj: unknown, path: string) => string` | |
|
|
813
|
+
| `formatPathLabel` | `(path: string) => string` | `"created_at"` → `"Created At"`. |
|
|
814
|
+
| `defaultGetRowId` | `(row: Row, index: number) => string` | `id ?? _id ?? uuid ?? index`. |
|
|
815
|
+
| `buildCardRows` | `(fields: readonly FieldConfig[], row: Row) => DataViewCardRow[]` | The card body, paired two per row. |
|
|
816
|
+
| `resolveBadgeVariant` | `(variant?: BadgeVariant) => { color, badgeStyle }` | |
|
|
817
|
+
| `SkeletonBar` · `skeletonKeys` | `({ className })` · `(n: number) => number[]` | The pieces every view's skeleton is built from. |
|
|
818
|
+
| `markView` · `markHeader` · `markPanel` | `(component, meta?) => component` | Register a part of your own. |
|
|
819
|
+
|
|
820
|
+
## TypeScript
|
|
821
|
+
|
|
822
|
+
Every type below is exported from `@/components/DataViews`.
|
|
823
|
+
|
|
824
|
+
### Row
|
|
825
|
+
|
|
826
|
+
```ts
|
|
827
|
+
type Row = Record<string, unknown>;
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
Your object, untouched. DataViews never reshapes it — dotted paths are read on the way out.
|
|
831
|
+
|
|
832
|
+
### FieldConfig
|
|
833
|
+
|
|
834
|
+
One entry per field. The order of the array is the default column order.
|
|
835
|
+
|
|
836
|
+
```ts
|
|
837
|
+
type FieldConfig = {
|
|
838
|
+
path: string; // "customer.name" — dotted paths are read for you
|
|
839
|
+
label?: string; // defaults to a title-cased `path`
|
|
840
|
+
type?: FieldType; // how to paint it — see the table below
|
|
841
|
+
visible?: boolean; // false hides it from `visibleFields`
|
|
842
|
+
render?: (value: unknown, row: Row) => ReactNode; // wins over `type`; the widest per-field seam
|
|
843
|
+
// …plus the per-type keys below
|
|
844
|
+
};
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
### Field types
|
|
848
|
+
|
|
849
|
+
All seventeen, with the extra keys each one reads. Anything unrecognised falls back to `text`.
|
|
850
|
+
|
|
851
|
+
| `type` | Extra keys it reads | Default when omitted |
|
|
852
|
+
| --- | --- | --- |
|
|
853
|
+
| `text` | — | `String(value)`; also the fallback for any unknown type |
|
|
854
|
+
| `number` | — | `value.toLocaleString()` |
|
|
855
|
+
| `date` | — | the raw string |
|
|
856
|
+
| `date-format` | `dateFormat` — token string (`YYYY MM DD HH mm ss`) or `Intl.DateTimeFormatOptions` | `{ year: "numeric", month: "short", day: "numeric" }` |
|
|
857
|
+
| `boolean` | `trueLabel` · `falseLabel` · `trueVariant` · `falseVariant` | `"Yes"`/`"No"`, green/gray. Never shows the `-` placeholder |
|
|
858
|
+
| `enum-badge` | `variants` (value → colour) · `defaultVariant` | `"gray"`; badge size `S` |
|
|
859
|
+
| `badge-array` | `variant` · `limit` | `"blue"`, no limit; the overflow chip is `+N` in gray, size `XS` |
|
|
860
|
+
| `currency` | `currency` — `"USD"` or `{ symbol, locale, decimals, code }` | symbol `"$"`; `Intl` currency style when `code` is set |
|
|
861
|
+
| `number-format` | `format: Intl.NumberFormatOptions` | plain `Intl.NumberFormat` |
|
|
862
|
+
| `progress-bar` | `thresholds: [warn, ok]` | `[40, 70]`; clamped 0–100; never shows the placeholder |
|
|
863
|
+
| `star-rating` | `max` | `5` |
|
|
864
|
+
| `icon-text` | `icon` (a `ri-*` class, else literal text) · `iconPosition` | `"before"` |
|
|
865
|
+
| `two-line` | `secondaryPath` | — |
|
|
866
|
+
| `avatar` | `fallbackPath` — where the initials come from | initials `"?"` |
|
|
867
|
+
| `link` | `linkType: "mailto" \| "tel" \| "url"` | `url` opens in a new tab with `rel="noopener noreferrer"`; `mailto:`/`tel:` are prefixed if absent |
|
|
868
|
+
| `image` | — | 40×40 rounded |
|
|
869
|
+
| `hidden` | — | renders `null` **and** is dropped from `visibleFields` |
|
|
870
|
+
|
|
871
|
+
`BadgeVariant` is one of `green · greenLight · cocktailGreen · yellow · redOrange · redLight ·
|
|
872
|
+
rose · purple · bluePurple · blue · navy · gray · highlight`.
|
|
873
|
+
|
|
874
|
+
### DataViewsQuery
|
|
875
|
+
|
|
876
|
+
The only state that leaves the component.
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
interface DataViewsQuery {
|
|
880
|
+
search: string;
|
|
881
|
+
filters: FilterState; // Record<path, FilterValue>
|
|
882
|
+
sort: { path: string; direction: "asc" | "desc" } | null;
|
|
883
|
+
page: number; // 1-based
|
|
884
|
+
pageSize: number;
|
|
885
|
+
}
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
### FilterValue
|
|
889
|
+
|
|
890
|
+
Three kinds, and which one a field produces is decided by the FormBuilder field you rendered — see
|
|
891
|
+
[The section types](#the-section-types).
|
|
892
|
+
|
|
893
|
+
```ts
|
|
894
|
+
type FilterValue =
|
|
895
|
+
| string[] // choice / multi-choice / text
|
|
896
|
+
| { kind: "number"; min?: number; max?: number } // slider
|
|
897
|
+
| { kind: "date"; from?: string; to?: string }; // date range, ISO YYYY-MM-DD, local time
|
|
898
|
+
|
|
899
|
+
type FilterState = Record<string, FilterValue>;
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
A cleared filter is **removed** from the object, never set to an empty value — so
|
|
903
|
+
`Object.keys(filters).length` is a truthful "is anything filtered".
|
|
904
|
+
|
|
905
|
+
### The rest
|
|
906
|
+
|
|
907
|
+
```ts
|
|
908
|
+
type RowGroup = { id: string; label: string; color?: ColumnColor; rows: Row[] };
|
|
909
|
+
type TreeNode = { id: string; row: Row; children: TreeNode[]; depth: number };
|
|
910
|
+
type MoveIntent = { id: string; from: string | null; to: string | null; index?: number };
|
|
911
|
+
type ColumnState = { path: string; label: string; visible: boolean };
|
|
912
|
+
type Sort = { path: string; direction: "asc" | "desc" } | null;
|
|
913
|
+
type Preset = { label: string; min?: number; max?: number }
|
|
914
|
+
| { label: string; from?: string; to?: string };
|
|
915
|
+
type SavedViewSnapshot = { filters: FilterState; sort: Sort; columns: readonly ColumnState[] };
|
|
916
|
+
type SavedView = { id: string; label: string; snapshot?: SavedViewSnapshot };
|
|
917
|
+
type ColumnColor = "gray" | "purple" | "orange" | "blue" | "green" | "red";
|
|
918
|
+
type TreePaneMode = "table" | "cards" | (string & {});
|
|
919
|
+
type RowRenderArgs = { row: Row; id: string; index: number; fields: readonly FieldConfig[] };
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
`TreeNode.depth` is required by the type but the view does not read it — it measures depth from the
|
|
923
|
+
nesting. Pass `0` and forget it.
|
|
924
|
+
|
|
925
|
+
`parseQuery` is the server half of `queryToParams` — a route handler imports it so the encoder and
|
|
926
|
+
the decoder cannot drift.
|
|
927
|
+
## Custom rendering
|
|
928
|
+
|
|
929
|
+
Every view can be repainted, and each seam hands you the same `{ row, id, index, fields }` — plus
|
|
930
|
+
whatever that view knows. `fields` is what the panel left **visible, in order**, and `Cell` paints
|
|
931
|
+
one the way every other view paints it, so a custom UI keeps the currency, badge colours and date
|
|
932
|
+
formats the rest of the component uses.
|
|
933
|
+
|
|
934
|
+
| Want | Use |
|
|
935
|
+
| --- | --- |
|
|
936
|
+
| one field, everywhere it appears | `FieldConfig.render(value, row)` |
|
|
937
|
+
| one cell, in the table only | `renderCell` — return `undefined` to fall through |
|
|
938
|
+
| the board's card | `renderCard` |
|
|
939
|
+
| the inbox list row | `renderItem` |
|
|
940
|
+
| a tree node's label / icon / meta | `renderNode` |
|
|
941
|
+
| a cell or card **inside the tree's pane** | `DataViews.Tree.Table renderCell` · `DataViews.Tree.Cards renderCard` |
|
|
942
|
+
| a whole extra mode in the tree's pane | `DataViews.Tree.Tab` |
|
|
943
|
+
| something in the pane's header | `paneActions` |
|
|
944
|
+
| the inbox's detail pane, or the tree's pane entirely | `children` |
|
|
945
|
+
| a whole new view | `markView(MyView, { defaultId, defaultLabel })` |
|
|
946
|
+
|
|
947
|
+
### A table cell
|
|
948
|
+
|
|
949
|
+
Return `undefined` for anything you do not want to touch — that cell falls through to the default,
|
|
950
|
+
so you only describe the exception.
|
|
951
|
+
|
|
952
|
+
```tsx
|
|
953
|
+
<DataViews.Table
|
|
954
|
+
renderCell={({ field, row }) =>
|
|
955
|
+
field.path === "total" ? (
|
|
956
|
+
<span className="flex items-center gap-2">
|
|
957
|
+
<Cell field={field} row={row} />
|
|
958
|
+
{Number(row.total) > 5000 && (
|
|
959
|
+
<Badge label="large" color="purple" badgeStyle="subtle" showIcon={false} />
|
|
960
|
+
)}
|
|
961
|
+
</span>
|
|
962
|
+
) : undefined
|
|
963
|
+
}
|
|
964
|
+
/>
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
`undefined` means "you paint it" and falls through to the default cell. `null` does not — it is a
|
|
968
|
+
deliberate blank, which is how you hide a value without hiding the column. One `renderCell` can
|
|
969
|
+
handle several fields:
|
|
970
|
+
|
|
971
|
+
```tsx
|
|
972
|
+
renderCell={({ field, row }) => {
|
|
973
|
+
if (field.path === "status" && row.archived) return null; // deliberately empty
|
|
974
|
+
if (field.path === "customer.name")
|
|
975
|
+
return (
|
|
976
|
+
<span className="flex items-center gap-2">
|
|
977
|
+
<Avatar src={String(row.avatar ?? "")} size="S" />
|
|
978
|
+
<Cell field={field} row={row} />
|
|
979
|
+
</span>
|
|
980
|
+
);
|
|
981
|
+
return undefined; // everything else: default
|
|
982
|
+
}}
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
### A board card
|
|
986
|
+
|
|
987
|
+
`renderCard` replaces the card's **content**; the board keeps the wrapper, so dragging, selection
|
|
988
|
+
and the click target keep working without wiring any of it. It also receives `group` and
|
|
989
|
+
`isDragging`.
|
|
990
|
+
|
|
991
|
+
```tsx
|
|
992
|
+
<DataViews.Board
|
|
993
|
+
groups={groups}
|
|
994
|
+
renderCard={({ row, fields, isActive }) => (
|
|
995
|
+
<div
|
|
996
|
+
className={cn(
|
|
997
|
+
"bg-background-presentation-form-base flex flex-col gap-1 rounded-[10px] border p-3",
|
|
998
|
+
isActive ? "border-border-presentation-state-focus" : "border-border-presentation-global-primary",
|
|
999
|
+
)}
|
|
1000
|
+
>
|
|
1001
|
+
<span className="typography-headers-large-semibold">
|
|
1002
|
+
<Cell field={fields[1]} row={row} />
|
|
1003
|
+
</span>
|
|
1004
|
+
<Cell field={fields[2]} row={row} />
|
|
1005
|
+
</div>
|
|
1006
|
+
)}
|
|
1007
|
+
/>
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
It also receives `group` and `isDragging` — the column the card is in, and whether this card is the
|
|
1011
|
+
one being dragged:
|
|
1012
|
+
|
|
1013
|
+
```tsx
|
|
1014
|
+
renderCard={({ row, fields, group, isDragging }) => (
|
|
1015
|
+
<div className={cn("rounded-[10px] border p-3", isDragging && "opacity-40")}>
|
|
1016
|
+
<Cell field={fields[1]} row={row} />
|
|
1017
|
+
<span className="typography-body-small-regular">{group.label}</span>
|
|
1018
|
+
</div>
|
|
1019
|
+
)}
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
### An inbox row
|
|
1023
|
+
|
|
1024
|
+
Same idea: the row keeps its own hover, selected and link behaviour, and `renderItem` fills it.
|
|
1025
|
+
|
|
1026
|
+
```tsx
|
|
1027
|
+
<DataViews.Inbox
|
|
1028
|
+
renderItem={({ row, fields, isActive }) => (
|
|
1029
|
+
<div className="flex items-center justify-between gap-2">
|
|
1030
|
+
<Cell field={fields[1]} row={row} />
|
|
1031
|
+
<span className="flex items-center gap-2">
|
|
1032
|
+
<Cell field={fields[2]} row={row} />
|
|
1033
|
+
{isActive && <i className="ri-arrow-right-line" aria-hidden />}
|
|
1034
|
+
</span>
|
|
1035
|
+
</div>
|
|
1036
|
+
)}
|
|
1037
|
+
/>
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
`isActive` is the open row, which is what you hang a read/unread treatment on. With `itemHref` the
|
|
1041
|
+
item becomes a link, and `linkComponent` makes it your router's:
|
|
1042
|
+
|
|
1043
|
+
```tsx
|
|
1044
|
+
<DataViews.Inbox
|
|
1045
|
+
itemHref={(row, id) => `/orders/${id}`}
|
|
1046
|
+
linkComponent={Link}
|
|
1047
|
+
placeholder={<p className="p-6">Pick an order.</p>}
|
|
1048
|
+
renderItem={({ row, fields, isActive }) => (
|
|
1049
|
+
<div className={cn("flex justify-between", !isActive && !row.read && "font-semibold")}>
|
|
1050
|
+
<Cell field={fields[1]} row={row} />
|
|
1051
|
+
<Cell field={fields[2]} row={row} />
|
|
1052
|
+
</div>
|
|
1053
|
+
)}
|
|
1054
|
+
/>
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
### A tree node
|
|
1058
|
+
|
|
1059
|
+
`renderNode` is the one that does **not** return markup. `TreeFolder` owns the row — the indent,
|
|
1060
|
+
the connector lines, the selection band, the drag grip — so it returns only the pieces that can
|
|
1061
|
+
vary, and anything richer belongs in the pane beside it.
|
|
1062
|
+
|
|
1063
|
+
```tsx
|
|
1064
|
+
<DataViews.Tree
|
|
1065
|
+
nodes={nodes}
|
|
1066
|
+
renderNode={({ row }) =>
|
|
1067
|
+
row.status ? { meta: <Badge label={String(row.status)} color="blue" badgeStyle="subtle" /> } : {}
|
|
1068
|
+
}
|
|
1069
|
+
/>
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
It may return `name`, `icon` and `meta` — anything omitted keeps the default, and returning `{}`
|
|
1073
|
+
leaves the node entirely alone:
|
|
1074
|
+
|
|
1075
|
+
```tsx
|
|
1076
|
+
renderNode={({ row, node }) => ({
|
|
1077
|
+
name: `${row.customer.name} (${node.children.length})`,
|
|
1078
|
+
icon: <i className={row.status === "Delivered" ? "ri-check-line" : "ri-time-line"} />,
|
|
1079
|
+
meta: <Badge label={String(row.status)} color="blue" badgeStyle="subtle" showIcon={false} />,
|
|
1080
|
+
})}
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
### A pane tab
|
|
1084
|
+
|
|
1085
|
+
The tree's pane takes a mode of your own beside List and Cards. A tab renders its children only
|
|
1086
|
+
while it is selected, and reads the **selected node's** rows — the pane scopes the data context, so
|
|
1087
|
+
nothing is threaded through props:
|
|
1088
|
+
|
|
1089
|
+
```tsx
|
|
1090
|
+
function Timeline() {
|
|
1091
|
+
const { rows } = useDataViewsData(); // the node's rows, already narrowed by `paneRows`
|
|
1092
|
+
return (
|
|
1093
|
+
<ol className="p-6">
|
|
1094
|
+
{rows.map((row) => (
|
|
1095
|
+
<li key={String(row.id)}>{String(row.createdAt)} — {String(row.customer.name)}</li>
|
|
1096
|
+
))}
|
|
1097
|
+
</ol>
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
<DataViews.Tree nodes={nodes} labelPath="name">
|
|
1102
|
+
<DataViews.Tree.Table selectable />
|
|
1103
|
+
<DataViews.Tree.Cards renderCard={({ row }) => <OrderCard row={row} />} />
|
|
1104
|
+
<DataViews.Tree.Tab value="timeline" label="Timeline" icon={<Clock />}>
|
|
1105
|
+
<Timeline />
|
|
1106
|
+
</DataViews.Tree.Tab>
|
|
1107
|
+
</DataViews.Tree>
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
The switch shows exactly what you rendered. Render one tab and there is no switch; render none and
|
|
1111
|
+
there is no pane. See [`tree-custom`](./examples/tree-custom.md).
|
|
1112
|
+
|
|
1113
|
+
### The detail pane
|
|
1114
|
+
|
|
1115
|
+
The `children` of `Inbox` and `Tree` **are** the pane. `DataViews.Detail` is a sensible default,
|
|
1116
|
+
not a requirement — write your own and `useActiveRow()` resolves whatever is open:
|
|
1117
|
+
|
|
1118
|
+
```tsx
|
|
1119
|
+
function OrderDetail() {
|
|
1120
|
+
const row = useActiveRow();
|
|
1121
|
+
const { visibleFields } = useDataViewsData();
|
|
1122
|
+
if (!row) return <p className="p-6">Select an order.</p>;
|
|
1123
|
+
return (
|
|
1124
|
+
<div className="flex flex-col gap-3 p-6">
|
|
1125
|
+
{visibleFields.map((field, i) => (
|
|
1126
|
+
<Cell key={`${field.path}-${i}`} field={field} row={row} />
|
|
1127
|
+
))}
|
|
1128
|
+
</div>
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
<DataViews.Inbox>
|
|
1133
|
+
<OrderDetail />
|
|
1134
|
+
</DataViews.Inbox>
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
In a tree, `useActiveRow()` returns nothing when the selected node is a synthetic branch rather
|
|
1138
|
+
than a row — say so in the pane rather than rendering an empty shell. See
|
|
1139
|
+
[One caveat](#one-caveat).
|
|
1140
|
+
|
|
1141
|
+
### A whole new view
|
|
1142
|
+
|
|
1143
|
+
`markView` registers it in the switcher beside the built-in four. A view is not a decoration — it
|
|
1144
|
+
is handed the same context they are, and is expected to honour the same contract: paint the fields
|
|
1145
|
+
the panel left visible, key rows by `getRowId`, show the house skeleton while `loading`, set
|
|
1146
|
+
`activeId` when one is opened, and ask for more when it runs out.
|
|
1147
|
+
|
|
1148
|
+
Here is a complete one — a timeline grouped by date, built on the library's own `Timeline`:
|
|
1149
|
+
|
|
1150
|
+
```tsx
|
|
1151
|
+
import {
|
|
1152
|
+
Cell, markView, skeletonKeys, SkeletonBar,
|
|
1153
|
+
useDataViewsData, useDataViewsView, type ViewBaseProps,
|
|
1154
|
+
} from "@/components/DataViews";
|
|
1155
|
+
import {
|
|
1156
|
+
Timeline, TimelineItem, TimelineIndicator,
|
|
1157
|
+
TimelineSeparator, TimelineConnector, TimelineContent, TimelineHeading,
|
|
1158
|
+
} from "@/components/Timeline";
|
|
1159
|
+
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; // not on the DataViews barrel
|
|
1160
|
+
import { getByPath } from "@/utils/dataViews/path";
|
|
1161
|
+
import { cn } from "@/utils/cn";
|
|
1162
|
+
|
|
1163
|
+
export const TimelineView = markView(
|
|
1164
|
+
function TimelineView({ className }: ViewBaseProps) {
|
|
1165
|
+
const { rows, visibleFields, getRowId, loading, loadingMore, hasMore, onLoadMore } =
|
|
1166
|
+
useDataViewsData();
|
|
1167
|
+
const { activeId, setActiveId } = useDataViewsView();
|
|
1168
|
+
|
|
1169
|
+
// Asks for the next page as the list nears its end. `hasMore` is derived by the root.
|
|
1170
|
+
const { sentinelRef } = useInfiniteScroll({
|
|
1171
|
+
onLoadMore,
|
|
1172
|
+
hasMore,
|
|
1173
|
+
loading: loading || loadingMore,
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
const [title, ...rest] = visibleFields;
|
|
1177
|
+
|
|
1178
|
+
// One bucket per day, in the order the rows arrived — the component never sorts.
|
|
1179
|
+
const byDay = new Map<string, typeof rows>();
|
|
1180
|
+
for (const row of rows) {
|
|
1181
|
+
const day = String(getByPath(row, "createdAt") ?? "—").slice(0, 10);
|
|
1182
|
+
byDay.set(day, [...(byDay.get(day) ?? []), row]);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
if (loading) {
|
|
1186
|
+
return (
|
|
1187
|
+
<div className={cn("bg-background-presentation-form-base flex flex-col gap-4 p-6", className)}>
|
|
1188
|
+
{skeletonKeys(6).map((i) => (
|
|
1189
|
+
<div key={i} className="flex items-center gap-3">
|
|
1190
|
+
<SkeletonBar className="h-[14px] w-[14px] shrink-0 rounded-full" />
|
|
1191
|
+
<SkeletonBar className={i % 2 ? "w-[45%]" : "w-[65%]"} />
|
|
1192
|
+
</div>
|
|
1193
|
+
))}
|
|
1194
|
+
</div>
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return (
|
|
1199
|
+
<div className={cn("bg-background-presentation-form-base h-full overflow-y-auto p-6", className)}>
|
|
1200
|
+
{[...byDay].map(([day, dayRows]) => (
|
|
1201
|
+
<section key={day}>
|
|
1202
|
+
<h3 className="typography-body-small-semibold text-content-presentation-global-secondary py-2">
|
|
1203
|
+
{day}
|
|
1204
|
+
</h3>
|
|
1205
|
+
<Timeline>
|
|
1206
|
+
{dayRows.map((row, index) => {
|
|
1207
|
+
const id = getRowId(row, index);
|
|
1208
|
+
return (
|
|
1209
|
+
<TimelineItem key={id}>
|
|
1210
|
+
<TimelineSeparator>
|
|
1211
|
+
<TimelineIndicator variant={activeId === id ? "active" : "default"} />
|
|
1212
|
+
<TimelineConnector />
|
|
1213
|
+
</TimelineSeparator>
|
|
1214
|
+
<TimelineContent
|
|
1215
|
+
role="button"
|
|
1216
|
+
tabIndex={0}
|
|
1217
|
+
onClick={() => setActiveId(activeId === id ? null : id)}
|
|
1218
|
+
className="cursor-pointer"
|
|
1219
|
+
>
|
|
1220
|
+
<TimelineHeading>
|
|
1221
|
+
{title && <Cell field={title} row={row} />}
|
|
1222
|
+
</TimelineHeading>
|
|
1223
|
+
<div className="flex items-center gap-2">
|
|
1224
|
+
{rest.slice(0, 2).map((field, i) => (
|
|
1225
|
+
<Cell key={`${field.path}-${i}`} field={field} row={row} />
|
|
1226
|
+
))}
|
|
1227
|
+
</div>
|
|
1228
|
+
</TimelineContent>
|
|
1229
|
+
</TimelineItem>
|
|
1230
|
+
);
|
|
1231
|
+
})}
|
|
1232
|
+
</Timeline>
|
|
1233
|
+
</section>
|
|
1234
|
+
))}
|
|
1235
|
+
|
|
1236
|
+
{/* The trigger, inside the scroller the rows live in. */}
|
|
1237
|
+
{hasMore && <div ref={sentinelRef as React.Ref<HTMLDivElement>} className="h-px" />}
|
|
1238
|
+
</div>
|
|
1239
|
+
);
|
|
1240
|
+
},
|
|
1241
|
+
{ defaultId: "timeline", defaultLabel: "Timeline" },
|
|
1242
|
+
);
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
Render it like any other view — it appears in the switcher, and disappears if you stop rendering it:
|
|
1246
|
+
|
|
1247
|
+
```tsx
|
|
1248
|
+
<DataViews rows={rows} fields={fields} total={total} onLoadMore={fetchNextPage}>
|
|
1249
|
+
<DataViews.Header title="Orders">
|
|
1250
|
+
<DataViews.ViewSwitch />
|
|
1251
|
+
</DataViews.Header>
|
|
1252
|
+
<DataViews.Table />
|
|
1253
|
+
<TimelineView icon={<i className="ri-time-line" />} />
|
|
1254
|
+
</DataViews>
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
Accept `ViewBaseProps` so the caller keeps `id`, `label`, `icon` and `className`.
|
|
1258
|
+
|
|
1259
|
+
### The chrome
|
|
1260
|
+
|
|
1261
|
+
Buttons you put in `Actions` are `<Button variant="BluColStyle" size="M">` by convention — the
|
|
1262
|
+
solid blue Figma uses for the bar's action, with `Search` and `PanelToggle` left ghost beside them.
|
|
1263
|
+
|
|
1264
|
+
Every titled group in the rail folds: `Panel.Columns`, `Panel.Sort` and `Panel.SavedViews` all
|
|
1265
|
+
render through `Panel.Section`, and `Filters` folds its fields the same way. Pass
|
|
1266
|
+
`collapsible={false}` to pin one open, or `defaultOpen={false}` to start it closed.
|
|
1267
|
+
|
|
1268
|
+
The surrounding parts take content too: `Header`'s `title` and `Actions`/`PanelToggle` children,
|
|
1269
|
+
any markup inside a `Panel.Tab` (with `Panel.Section` to group it), `Filters.Custom` for a filter
|
|
1270
|
+
no FormBuilder field covers, and `Inbox`'s `placeholder` for the empty pane.
|
|
1271
|
+
|
|
1272
|
+
## Dragging
|
|
1273
|
+
|
|
1274
|
+
Four surfaces drag — board cards between columns, table rows into a manual order, the rail's
|
|
1275
|
+
column list, and tree nodes into a new parent — and all four **work with a finger and with the
|
|
1276
|
+
keyboard**: hold to pick up and swipe to scroll, or Space, arrows, Space. Each is opt-in by handing
|
|
1277
|
+
over a callback, and none of them move anything on their own; the item settles where it landed only
|
|
1278
|
+
once you hand back reordered data.
|
|
1279
|
+
|
|
1280
|
+
A table's manual order and a sort are two different orders, and the component cannot know which one
|
|
1281
|
+
you meant. It reports the drop; deciding is yours.
|
|
1282
|
+
|
|
1283
|
+
## Two colour worlds
|
|
1284
|
+
|
|
1285
|
+
The chrome is always dark and the content is not. `data-theme="dark"` is scoped to the header bar
|
|
1286
|
+
and the settings rail so their literals resolve correctly no matter what theme the host app runs
|
|
1287
|
+
in. The views inside keep the host theme, which is why the table reads white and the board grey.
|
|
1288
|
+
The Master Container carries the surface — a form base, a 1px global border and a 16px radius — so
|
|
1289
|
+
anything positioned around a view inherits it.
|
|
1290
|
+
|
|
1291
|
+
## Accessibility
|
|
1292
|
+
|
|
1293
|
+
What is actually implemented, rather than what a data grid usually claims:
|
|
1294
|
+
|
|
1295
|
+
- **Sorting is buttons.** Each column header is a real button carrying its own `sortLabel`, so a
|
|
1296
|
+
screen reader hears "Customer, sort ascending" rather than one identical "Sort ascending" per
|
|
1297
|
+
column. Clicking cycles asc → desc → unsorted, so a third press returns the server's own order.
|
|
1298
|
+
- **Rows are only focusable when they do something.** `<tr>` has no implicit role, so a row gains
|
|
1299
|
+
`role="button"`, `tabIndex` and Enter/Space handling **only** when `onRowClick` is passed —
|
|
1300
|
+
otherwise it stays out of the tab order instead of being an announced control that does nothing.
|
|
1301
|
+
- **Dragging works without a pointer.** Space lifts, arrows move, Space drops, Escape cancels — on
|
|
1302
|
+
all four draggable surfaces. Grips are labelled (`"Reorder row"`), and touch drags start on a
|
|
1303
|
+
200ms hold so a swipe still scrolls the list.
|
|
1304
|
+
- **The view switcher is a tablist** (`role="tab"` / `aria-selected`), and the tree exposes
|
|
1305
|
+
`aria-expanded` on branches and `aria-current` on the open node.
|
|
1306
|
+
- **RTL** works from logical properties (`ms-*`, `ps-*`, `start-*`) rather than left/right, so the
|
|
1307
|
+
whole component mirrors under `dir="rtl"` — including the panel rail, the filter controls and the
|
|
1308
|
+
tree's indentation. `app/data-views/a11y-rtl/page.tsx` toggles it live.
|
|
1309
|
+
- **Decorative things are hidden.** Connector lines, drag grips, skeletons and layout spacers all
|
|
1310
|
+
carry `aria-hidden`, so the reading order is the data.
|
|
1311
|
+
|
|
1312
|
+
The one thing to supply yourself: `fields[].label`. Without it a column falls back to its `path`,
|
|
1313
|
+
and `customer.name` is what the sort control will announce.
|
|
1314
|
+
|
|
1315
|
+
## Common Patterns
|
|
1316
|
+
|
|
1317
|
+
### Fetch on every query change
|
|
1318
|
+
|
|
1319
|
+
The whole component in one line of plumbing: the query is the query key, so TanStack refetches when
|
|
1320
|
+
it changes and discards a superseded response.
|
|
1321
|
+
|
|
1322
|
+
```tsx
|
|
1323
|
+
const [query, setQuery] = useState(emptyQuery());
|
|
1324
|
+
const { data, isPending } = useQuery({
|
|
1325
|
+
queryKey: ["orders", query],
|
|
1326
|
+
queryFn: () => fetch(`/api/orders?${queryToParams(query)}`).then((r) => r.json()),
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
<DataViews rows={data?.rows ?? []} total={data?.total ?? 0} fields={FIELDS}
|
|
1330
|
+
loading={isPending} onQueryChange={setQuery}>…</DataViews>
|
|
1331
|
+
```
|
|
1332
|
+
|
|
1333
|
+
### Decode it on the server
|
|
1334
|
+
|
|
1335
|
+
`parseQuery` is the other half, so the two cannot drift:
|
|
1336
|
+
|
|
1337
|
+
```ts
|
|
1338
|
+
// app/api/orders/route.ts
|
|
1339
|
+
export async function GET(request: Request) {
|
|
1340
|
+
const { search, filters, sort, page, pageSize } = parseQuery(new URL(request.url));
|
|
1341
|
+
// …your matcher, your ORM. Return { rows, total }.
|
|
1342
|
+
}
|
|
1343
|
+
```
|
|
1344
|
+
|
|
1345
|
+
### Persist what the user chose
|
|
1346
|
+
|
|
1347
|
+
Only the query leaves, so persistence is a two-line round trip — seed with a `default*` prop, save
|
|
1348
|
+
from the `on*Change`:
|
|
1349
|
+
|
|
1350
|
+
```tsx
|
|
1351
|
+
<DataViews defaultQuery={loadQuery()} onQueryChange={(q) => { save(q); setQuery(q); }}>
|
|
1352
|
+
<DataViews.Tree defaultPaneMode={loadPref()} onPaneModeChange={savePref} />
|
|
1353
|
+
```
|
|
1354
|
+
|
|
1355
|
+
Saved views are the same shape at a larger grain: `Panel.SavedViews` hands you a
|
|
1356
|
+
`SavedViewSnapshot` on save, and restores whatever you hand back.
|
|
1357
|
+
|
|
1358
|
+
### Drag that survives a failed save
|
|
1359
|
+
|
|
1360
|
+
`onRowMove` reports intent; nothing moves until you hand back rows that agree. That is what makes a
|
|
1361
|
+
failed save leave the board showing the truth:
|
|
1362
|
+
|
|
1363
|
+
```tsx
|
|
1364
|
+
<DataViews.Board groups={groups} onRowMove={(intent) => {
|
|
1365
|
+
if (intent.to) move.mutate({ id: Number(intent.id), status: intent.to });
|
|
1366
|
+
}} />
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
### One dataset, two tabs of the same view
|
|
1370
|
+
|
|
1371
|
+
Views are registered by rendering them, and `id`/`label` name them — so the same view twice is just
|
|
1372
|
+
two elements:
|
|
1373
|
+
|
|
1374
|
+
```tsx
|
|
1375
|
+
<DataViews.Board id="by-status" label="Status" groups={byStatus} />
|
|
1376
|
+
<DataViews.Board id="by-owner" label="Owner" groups={byOwner} />
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
## Testing
|
|
1380
|
+
|
|
1381
|
+
The component is pure UI, so the assertions worth writing are about **what left** and **what was
|
|
1382
|
+
painted** — never about internal state.
|
|
1383
|
+
|
|
1384
|
+
```tsx
|
|
1385
|
+
it("reports a filter through onQueryChange", async () => {
|
|
1386
|
+
const onQueryChange = vi.fn();
|
|
1387
|
+
render(<Orders onQueryChange={onQueryChange} />);
|
|
1388
|
+
await userEvent.click(screen.getByRole("button", { name: /filter & config/i }));
|
|
1389
|
+
await userEvent.click(screen.getByRole("checkbox", { name: "Shipped" }));
|
|
1390
|
+
|
|
1391
|
+
expect(onQueryChange).toHaveBeenLastCalledWith(
|
|
1392
|
+
expect.objectContaining({ filters: { status: ["Shipped"] }, page: 1 }), // page reset
|
|
1393
|
+
);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
it("paints nothing when there is nothing", () => {
|
|
1397
|
+
render(<DataViews rows={[]} total={0} fields={FIELDS}><DataViews.Table /></DataViews>);
|
|
1398
|
+
expect(screen.getByRole("columnheader", { name: "Order #" })).toBeInTheDocument();
|
|
1399
|
+
expect(screen.queryAllByRole("row")).toHaveLength(1); // the header band only
|
|
1400
|
+
});
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
Notes that save time:
|
|
1404
|
+
|
|
1405
|
+
- The search box is an **input inside an expanding button** — click the button first.
|
|
1406
|
+
- A combobox filter renders as `role="combobox"` on an `<input>`, not a `<button>`.
|
|
1407
|
+
- Sortable column headers are **buttons inside the `columnheader`**.
|
|
1408
|
+
- The tree's pane has its own `role="tablist"`, distinct from the header's view switcher — scope
|
|
1409
|
+
the query or you will assert against the wrong one.
|
|
1410
|
+
- Drag is `@dnd-kit`: it needs pointer events with an 8px move, not `fireEvent.dragStart`. Keyboard
|
|
1411
|
+
drag (Space, arrows, Space) is usually the cheaper test.
|
|
1412
|
+
|
|
1413
|
+
## Performance
|
|
1414
|
+
|
|
1415
|
+
| Concern | What the component already does | What is yours |
|
|
1416
|
+
| --- | --- | --- |
|
|
1417
|
+
| Large row counts | The table renders a window past **300 rows**; below that every row is real, which is what keeps row drag and column resize simple | Keep `rows` a stable reference — `data?.rows ?? []` is a new array every render and will re-run every memo downstream |
|
|
1418
|
+
| Paging | `onLoadMore` fires once per arrival at the end, latched until the sentinel leaves | Append pages; never replace |
|
|
1419
|
+
| Re-renders | `visibleFields`, `groups` and `nodes` are read straight through | `useMemo` your `groups`/`nodes` builders — they run on every render otherwise |
|
|
1420
|
+
| Query churn | `page` resets internally when the query narrows | Exclude `page` from your query key when using `useInfiniteQuery`, or every page refetches the lot |
|
|
1421
|
+
| Cell cost | `Cell` is a switch on `type` | A `render` that mounts a heavy subtree runs per visible cell — keep it cheap or memoize it |
|
|
1422
|
+
|
|
1423
|
+
The board and inbox load on scroll but do **not** virtualize. The tree does neither: a tree wants
|
|
1424
|
+
its children fetched when a node expands, not its siblings paged in, and that is not built.
|
|
1425
|
+
|
|
1426
|
+
## Styling
|
|
1427
|
+
|
|
1428
|
+
`className` on any part is merged through `cn`, so a Tailwind class wins over the default. Two
|
|
1429
|
+
things are worth knowing before you reach for it.
|
|
1430
|
+
|
|
1431
|
+
**The chrome is always dark.** The header bar and the settings rail carry `data-theme="dark"`
|
|
1432
|
+
regardless of the `theme` you pass; `theme` themes the **content**. See
|
|
1433
|
+
[Two colour worlds](#two-colour-worlds).
|
|
1434
|
+
|
|
1435
|
+
**Colour comes from tokens, never literals.** Use `presentation` tokens
|
|
1436
|
+
(`bg-background-presentation-*`, `text-content-presentation-*`, `border-border-presentation-*`) so a
|
|
1437
|
+
part sits correctly in both worlds. Never use `system` tokens or `variant="SystemStyle"`.
|
|
1438
|
+
|
|
1439
|
+
Two `className`s **replace** rather than merge, because they are layout, not decoration:
|
|
1440
|
+
`DataViews.Tree.Cards`'s grid classes, and `Filters`'s when you pass `title={null}`.
|
|
1441
|
+
|
|
1442
|
+
## Known Limitations
|
|
1443
|
+
|
|
1444
|
+
| Limitation | Why | What to do |
|
|
1445
|
+
| --- | --- | --- |
|
|
1446
|
+
| No boolean filter section | `AS_FILTER` maps FormBuilder kinds to `text · choice · multiChoice · date · slider`; a checkbox has no filter meaning | Use a `RadioList` of Yes/No, or `Filters.Custom` |
|
|
1447
|
+
| The tree does not virtualize or page | Trees want lazy children, not paged siblings | Fetch a node's children on expand and hand back new `nodes` |
|
|
1448
|
+
| `Detail` is row-keyed, not node-keyed | It resolves `activeId` against `rows` | See [One caveat](#one-caveat) |
|
|
1449
|
+
| A pane sort re-queries | The pane's table writes sort into the shared query | Sort inside `paneRows` for a self-contained pane |
|
|
1450
|
+
| Filters cannot express OR | The component reports what was chosen; combining is your matcher's business | Interpret `FilterState` however you like server-side |
|
|
1451
|
+
| Two views of one dataset share one query | That is the design — a switch must not lose the user's filters | Mount two `DataViews` if they genuinely need separate queries |
|
|
1452
|
+
|
|
1453
|
+
## Troubleshooting
|
|
1454
|
+
|
|
1455
|
+
| Symptom | Cause | Fix |
|
|
1456
|
+
| --- | --- | --- |
|
|
1457
|
+
| Nothing renders, or "DataViews parts must be rendered inside `<DataViews>`" | A part is outside the root, or wrapped in a component of your own | Wrap the wrapper with `markView`/`markHeader`/`markPanel` |
|
|
1458
|
+
| A view has no tab | The element is not rendered, or is behind a falsy condition | A part exists because you rendered it |
|
|
1459
|
+
| Filtering does nothing | You are waiting for the component to filter | It does not. Fetch with the new query and hand back new `rows` |
|
|
1460
|
+
| A filter never appears in `filters` | The field's kind has no filter meaning (`Checkbox`, `File`, `Custom`) | Use a supported control or `Filters.Custom` |
|
|
1461
|
+
| `setValue("customer.name", …)` does nothing | react-hook-form reads `.` as nesting | Use the escaped name — `customer__name` |
|
|
1462
|
+
| Scroll loading fires repeatedly | `hasMore` never goes false | It is derived from `rows.length < total` — check `total` |
|
|
1463
|
+
| Scroll loading never fires | Rows were replaced instead of appended | Append each page |
|
|
1464
|
+
| The tree shows no pane | No tabs were passed | Render `<DataViews.Tree.Table/>` / `.Cards`, or anything else as the pane |
|
|
1465
|
+
| A pane tab shows the whole dataset | It read rows from outside the pane | Read `useDataViewsData()` **inside** the tab — the pane scopes it |
|
|
1466
|
+
| Drag does nothing on a phone | Nothing is wrong; hold 200ms first | A shorter delay cannot be told apart from a scroll |
|
|
1467
|
+
| Types will not import | Reaching into `@/utils/dataViews/types` | Everything is re-exported from `@/components/DataViews` |
|
|
1468
|
+
|
|
1469
|
+
## Example pages
|
|
1470
|
+
|
|
1471
|
+
Every one is a complete, runnable page — generated into the docs from the app, so the code below
|
|
1472
|
+
travels with the package rather than living in a repo you may not have.
|
|
1473
|
+
|
|
1474
|
+
| Page | Shows |
|
|
1475
|
+
| --- | --- |
|
|
1476
|
+
| [`overview`](./examples/overview.md) | Every part at once — the fastest way to see the whole shape |
|
|
1477
|
+
| [`views`](./examples/views.md) | All four views over one dataset, with drag round-trips |
|
|
1478
|
+
| [`tree-custom`](./examples/tree-custom.md) | Every custom-UI seam of the tree: `renderNode`, `paneRows`, a custom cell, card, tab, `paneActions`, and a whole-pane override |
|
|
1479
|
+
| [`inbox-routing`](./examples/inbox-routing.md) | `itemHref` + `linkComponent` — the pane driven by the URL |
|
|
1480
|
+
| [`fields`](./examples/fields.md) | The field types, painted |
|
|
1481
|
+
| [`filters`](./examples/filters.md) | Every filter control, presets, custom filters, the summary |
|
|
1482
|
+
| [`server-side`](./examples/server-side.md) | `queryToParams` out, `parseQuery` in |
|
|
1483
|
+
| [`scale`](./examples/scale.md) | Virtualization and scroll loading at size |
|
|
1484
|
+
| [`panel`](./examples/panel.md) | The rail: saved views, columns, sort — and the pane-mode round trip |
|
|
1485
|
+
| [`state`](./examples/state.md) | Controlled vs uncontrolled query |
|
|
1486
|
+
| [`view-registry`](./examples/view-registry.md) | A view of your own via `markView`, beside the built-in four |
|
|
1487
|
+
| [`a11y-rtl`](./examples/a11y-rtl.md) | Keyboard paths and the RTL mirror |
|
|
1488
|
+
|
|
1489
|
+
## Related Components
|
|
1490
|
+
|
|
1491
|
+
| Component | When |
|
|
1492
|
+
| --- | --- |
|
|
1493
|
+
| [`Table`](../table.md) | You need one table, not several views of one dataset. `DataViews.Table` composes it. |
|
|
1494
|
+
| [`DataTable`](../data-table.md) | A self-contained TanStack table that sorts, filters and pages **client-side**. Reach for it when the data is already in the browser; reach for `DataViews` when the server owns the query. |
|
|
1495
|
+
| [`TreeFolder`](../tree-folder.md) | The tree on its own, without the surrounding views. |
|
|
1496
|
+
| [`FormBuilder`](../form-builder.md) | Writes the filter controls — `DataViews.Filters` takes its fields as children. |
|
|
1497
|
+
| `DataViewCard` | The card the board renders (a layout, `@/layouts/DataViewCard`), usable directly. |
|
|
1498
|
+
|
|
1499
|
+
## One caveat
|
|
1500
|
+
|
|
1501
|
+
`DataViews.Detail` resolves `activeId` against `rows` via `getRowId`. The tree selects a **node**
|
|
1502
|
+
id, so `Detail` fills in only when the node's id is also a row id. Select a synthetic grouping node
|
|
1503
|
+
and there is no matching row, so it renders nothing. Either key your leaf nodes by row id, or
|
|
1504
|
+
render your own pane.
|