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,383 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { Children, useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
import { defaultGetRowId } from "../../utils/dataViews/path";
|
|
6
|
+
import { emptyQuery } from "../../utils/dataViews/query";
|
|
7
|
+
import type {
|
|
8
|
+
ColumnState,
|
|
9
|
+
DataViewsQuery,
|
|
10
|
+
FieldConfig,
|
|
11
|
+
FilterState,
|
|
12
|
+
Sort,
|
|
13
|
+
} from "../../utils/dataViews/types";
|
|
14
|
+
import {
|
|
15
|
+
DataContext,
|
|
16
|
+
FiltersContext,
|
|
17
|
+
PanelContext,
|
|
18
|
+
ViewContext,
|
|
19
|
+
type RegisteredView,
|
|
20
|
+
} from "./context";
|
|
21
|
+
import { Actions, Header, PanelToggle, Search, ViewSwitch } from "./header";
|
|
22
|
+
import {
|
|
23
|
+
isHeaderElement,
|
|
24
|
+
isPanelElement,
|
|
25
|
+
isViewElement,
|
|
26
|
+
viewMetaOf,
|
|
27
|
+
} from "./slots";
|
|
28
|
+
import { collectFilterFields, Filters } from "./filters";
|
|
29
|
+
import { Panel } from "./panel";
|
|
30
|
+
import { BoardView } from "./views/board-view";
|
|
31
|
+
import { Detail, InboxView } from "./views/inbox-view";
|
|
32
|
+
import { TableView } from "./views/table-view";
|
|
33
|
+
import { TreeView } from "./views/tree-view";
|
|
34
|
+
import { useControllable } from "./hooks/useControllable";
|
|
35
|
+
import type { DataViewsRootProps } from "./types";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A cheap identity for a node used only as a memo key. React elements have no stable identity
|
|
39
|
+
* across renders, so an inline `<i className="ri-table-line"/>` would look new every time; the
|
|
40
|
+
* element's props are what actually distinguish one icon from another.
|
|
41
|
+
*/
|
|
42
|
+
function iconKey(icon: React.ReactNode): string {
|
|
43
|
+
if (icon === null || icon === undefined || typeof icon === "boolean") return "";
|
|
44
|
+
if (React.isValidElement(icon)) return JSON.stringify(icon.props ?? {});
|
|
45
|
+
return String(icon);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Root ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve which fields to paint and in what order.
|
|
52
|
+
*
|
|
53
|
+
* The old code sorted `visibleFields` at five call sites with subtly different tie-breaks. There
|
|
54
|
+
* is one rule now: if `columns` is supplied it decides both visibility and order; anything not
|
|
55
|
+
* mentioned in it keeps its declaration order at the end.
|
|
56
|
+
*/
|
|
57
|
+
function resolveVisibleFields(
|
|
58
|
+
fields: readonly FieldConfig[],
|
|
59
|
+
columns: readonly ColumnState[] | undefined,
|
|
60
|
+
): FieldConfig[] {
|
|
61
|
+
if (!columns || columns.length === 0) {
|
|
62
|
+
return fields.filter((f) => f.visible !== false && f.type !== "hidden");
|
|
63
|
+
}
|
|
64
|
+
const byPath = new Map(fields.map((f) => [f.path, f]));
|
|
65
|
+
const ordered: FieldConfig[] = [];
|
|
66
|
+
for (const col of columns) {
|
|
67
|
+
const field = byPath.get(col.path);
|
|
68
|
+
if (field && col.visible) ordered.push(field);
|
|
69
|
+
byPath.delete(col.path);
|
|
70
|
+
}
|
|
71
|
+
for (const field of byPath.values()) {
|
|
72
|
+
if (field.visible !== false && field.type !== "hidden") ordered.push(field);
|
|
73
|
+
}
|
|
74
|
+
return ordered;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function DataViewsRoot({
|
|
78
|
+
rows,
|
|
79
|
+
fields,
|
|
80
|
+
children,
|
|
81
|
+
getRowId = defaultGetRowId,
|
|
82
|
+
total = 0,
|
|
83
|
+
loading = false,
|
|
84
|
+
onLoadMore,
|
|
85
|
+
loadingMore = false,
|
|
86
|
+
query,
|
|
87
|
+
onQueryChange,
|
|
88
|
+
defaultQuery,
|
|
89
|
+
defaultView,
|
|
90
|
+
defaultPanelOpen = false,
|
|
91
|
+
onViewChange,
|
|
92
|
+
onSelectionChange,
|
|
93
|
+
onActiveIdChange,
|
|
94
|
+
theme,
|
|
95
|
+
className,
|
|
96
|
+
}: DataViewsRootProps) {
|
|
97
|
+
const childArray = Children.toArray(children);
|
|
98
|
+
|
|
99
|
+
// A view exists because you rendered it. `Children.toArray` drops `false`/`null`, so
|
|
100
|
+
// `{canSeeTree && <DataViews.Tree/>}` un-registers that view — which is the intent.
|
|
101
|
+
const viewElements = childArray.filter(isViewElement);
|
|
102
|
+
const headerEl = childArray.find(isHeaderElement);
|
|
103
|
+
const panelEl = childArray.find(isPanelElement);
|
|
104
|
+
// Anything the root does not position itself — a `Filters` bar, a toolbar of your own — sits
|
|
105
|
+
// between the header and the views, in the order you wrote it.
|
|
106
|
+
const extras = childArray.filter(
|
|
107
|
+
(n) =>
|
|
108
|
+
!isViewElement(n) && !isHeaderElement(n) && !isPanelElement(n),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// `viewElements` is a fresh array on every render, so memoising on its identity would never
|
|
112
|
+
// hit. Key on what actually matters instead: which views are rendered and how they present.
|
|
113
|
+
// `icon` is part of that — leave it out and swapping only an icon leaves a stale one showing.
|
|
114
|
+
const viewKey = viewElements
|
|
115
|
+
.map((el) => `${el.props.id}|${el.props.label}|${iconKey(el.props.icon)}`)
|
|
116
|
+
.join(",");
|
|
117
|
+
const registered = useMemo<RegisteredView[]>(
|
|
118
|
+
() =>
|
|
119
|
+
viewElements.map((el) => {
|
|
120
|
+
const meta = viewMetaOf(el)!;
|
|
121
|
+
return {
|
|
122
|
+
id: el.props.id ?? meta.defaultId,
|
|
123
|
+
label: el.props.label ?? meta.defaultLabel,
|
|
124
|
+
icon: el.props.icon,
|
|
125
|
+
};
|
|
126
|
+
}),
|
|
127
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on `viewKey`, see above
|
|
128
|
+
[viewKey],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const firstId = registered[0]?.id ?? "";
|
|
132
|
+
|
|
133
|
+
// ── The query: the only state that leaves ──────────────────────────────────
|
|
134
|
+
const [currentQuery, setQuery] = useControllable<DataViewsQuery>(
|
|
135
|
+
query,
|
|
136
|
+
onQueryChange,
|
|
137
|
+
emptyQuery(defaultQuery),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Change part of the query.
|
|
142
|
+
*
|
|
143
|
+
* Anything that changes *which* rows match starts a new result set, so the page goes back to 1 —
|
|
144
|
+
* filter down to three rows while sitting on page 4 and there is no page 4 to stay on. Only the
|
|
145
|
+
* component can do this: by the time a caller sees the change, the two have already been
|
|
146
|
+
* conflated into one object.
|
|
147
|
+
*/
|
|
148
|
+
const patchQuery = useCallback(
|
|
149
|
+
(patch: Partial<DataViewsQuery>) => {
|
|
150
|
+
setQuery((prev) => {
|
|
151
|
+
const changesResultSet =
|
|
152
|
+
("search" in patch && patch.search !== prev.search) ||
|
|
153
|
+
("filters" in patch && patch.filters !== prev.filters) ||
|
|
154
|
+
("sort" in patch && patch.sort !== prev.sort) ||
|
|
155
|
+
("pageSize" in patch && patch.pageSize !== prev.pageSize);
|
|
156
|
+
return { ...prev, ...patch, ...(changesResultSet ? { page: 1 } : null) };
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
[setQuery],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// ── UI state: never leaves, so it is held rather than passed ───────────────
|
|
163
|
+
const [currentView, setCurrentView] = useState(defaultView ?? firstId);
|
|
164
|
+
const [currentColumns, setColumns] = useState<readonly ColumnState[]>([]);
|
|
165
|
+
const [currentSelection, setSelectionState] = useState<readonly string[]>([]);
|
|
166
|
+
const [currentActiveId, setActiveIdState] = useState<string | null>(null);
|
|
167
|
+
const [isPanelOpen, setPanelOpen] = useState(defaultPanelOpen);
|
|
168
|
+
|
|
169
|
+
// The observers are told, not asked: they report what the component decided, so an app can act
|
|
170
|
+
// on a selection or follow the open row without owning either.
|
|
171
|
+
const setSelection = useCallback(
|
|
172
|
+
(ids: readonly string[]) => {
|
|
173
|
+
setSelectionState(ids);
|
|
174
|
+
onSelectionChange?.(ids);
|
|
175
|
+
},
|
|
176
|
+
[onSelectionChange],
|
|
177
|
+
);
|
|
178
|
+
const setActiveId = useCallback(
|
|
179
|
+
(id: string | null) => {
|
|
180
|
+
setActiveIdState(id);
|
|
181
|
+
onActiveIdChange?.(id);
|
|
182
|
+
},
|
|
183
|
+
[onActiveIdChange],
|
|
184
|
+
);
|
|
185
|
+
const setView = useCallback(
|
|
186
|
+
(id: string) => {
|
|
187
|
+
setCurrentView(id);
|
|
188
|
+
onViewChange?.(id);
|
|
189
|
+
},
|
|
190
|
+
[onViewChange],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// If the active view stops being rendered, converge on the first one — in an effect, never
|
|
194
|
+
// during render, so a controlled consumer's state actually catches up.
|
|
195
|
+
const known = registered.some((v) => v.id === currentView);
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!known && firstId) setView(firstId);
|
|
198
|
+
}, [known, firstId, setView]);
|
|
199
|
+
|
|
200
|
+
const activeView = known ? currentView : firstId;
|
|
201
|
+
const activeElement = viewElements.find(
|
|
202
|
+
(el) => (el.props.id ?? viewMetaOf(el)!.defaultId) === activeView,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const visibleFields = useMemo(
|
|
206
|
+
() => resolveVisibleFields(fields, currentColumns),
|
|
207
|
+
[fields, currentColumns],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const dataValue = useMemo(
|
|
211
|
+
() => ({
|
|
212
|
+
rows,
|
|
213
|
+
fields,
|
|
214
|
+
visibleFields,
|
|
215
|
+
getRowId,
|
|
216
|
+
loading,
|
|
217
|
+
loadingMore,
|
|
218
|
+
// Derived, never passed: the component has both halves already, and a `hasMore` prop that
|
|
219
|
+
// disagreed with the rows on screen would be a second source of truth for the same fact.
|
|
220
|
+
hasMore: rows.length < total,
|
|
221
|
+
onLoadMore,
|
|
222
|
+
}),
|
|
223
|
+
[rows, fields, visibleFields, getRowId, loading, loadingMore, total, onLoadMore],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const setSearch = useCallback((search: string) => patchQuery({ search }), [patchQuery]);
|
|
227
|
+
const setSort = useCallback((sort: Sort) => patchQuery({ sort }), [patchQuery]);
|
|
228
|
+
|
|
229
|
+
const viewValue = useMemo(
|
|
230
|
+
() => ({
|
|
231
|
+
view: activeView,
|
|
232
|
+
setView,
|
|
233
|
+
views: registered,
|
|
234
|
+
search: currentQuery.search,
|
|
235
|
+
setSearch,
|
|
236
|
+
sort: currentQuery.sort,
|
|
237
|
+
setSort,
|
|
238
|
+
selection: currentSelection,
|
|
239
|
+
setSelection,
|
|
240
|
+
activeId: currentActiveId,
|
|
241
|
+
setActiveId,
|
|
242
|
+
columns: currentColumns,
|
|
243
|
+
setColumns,
|
|
244
|
+
}),
|
|
245
|
+
[
|
|
246
|
+
activeView,
|
|
247
|
+
setView,
|
|
248
|
+
registered,
|
|
249
|
+
currentQuery.search,
|
|
250
|
+
setSearch,
|
|
251
|
+
currentQuery.sort,
|
|
252
|
+
setSort,
|
|
253
|
+
currentSelection,
|
|
254
|
+
setSelection,
|
|
255
|
+
currentActiveId,
|
|
256
|
+
setActiveId,
|
|
257
|
+
currentColumns,
|
|
258
|
+
setColumns,
|
|
259
|
+
],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const panelValue = useMemo(
|
|
263
|
+
() => ({ open: isPanelOpen, setOpen: setPanelOpen }),
|
|
264
|
+
[isPanelOpen, setPanelOpen],
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// The descriptors as well as the value: `Filters.Summary` needs labels for its chips, and it is
|
|
268
|
+
// routinely rendered outside `Filters` — above the table, in a toolbar — where it cannot reach
|
|
269
|
+
// the context `Filters` provides to its own children.
|
|
270
|
+
const filterFields = useMemo(() => collectFilterFields(children), [children]);
|
|
271
|
+
|
|
272
|
+
const setFilters = useCallback(
|
|
273
|
+
(filters: FilterState) => patchQuery({ filters }),
|
|
274
|
+
[patchQuery],
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const filtersValue = useMemo(
|
|
278
|
+
() => ({ filters: currentQuery.filters, setFilters, filterFields }),
|
|
279
|
+
[currentQuery.filters, setFilters, filterFields],
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// The active view is always what renders. Nothing to show is shown as nothing — the view keeps
|
|
283
|
+
// its chrome and paints no rows — and `loading` is answered by the view's own skeleton, so the
|
|
284
|
+
// layout never swaps out from under the user.
|
|
285
|
+
const body = activeElement;
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<DataContext.Provider value={dataValue}>
|
|
289
|
+
<ViewContext.Provider value={viewValue}>
|
|
290
|
+
<PanelContext.Provider value={panelValue}>
|
|
291
|
+
<FiltersContext.Provider value={filtersValue}>
|
|
292
|
+
<div
|
|
293
|
+
data-theme={theme}
|
|
294
|
+
className={cn(
|
|
295
|
+
// The shell is always black: the dark header and the config rail sit on it, and
|
|
296
|
+
// the Master Container is the light surface inside. `overflow-hidden` traps child
|
|
297
|
+
// overflow — without it a tall panel body escapes and adds a page-level scrollbar
|
|
298
|
+
// on top of the panel's own.
|
|
299
|
+
"flex h-full min-h-0 gap-2 overflow-hidden bg-black",
|
|
300
|
+
"text-content-presentation-global-primary",
|
|
301
|
+
className,
|
|
302
|
+
)}
|
|
303
|
+
>
|
|
304
|
+
{/* Left column: header + content. Shrinks as the panel expands. */}
|
|
305
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
306
|
+
{headerEl}
|
|
307
|
+
<main className="flex min-h-0 flex-1 overflow-hidden">
|
|
308
|
+
{/* Master Container. It carries the surface — a form base and a 16px radius —
|
|
309
|
+
so views paint over it where they want a different colour (the board reads
|
|
310
|
+
grey, the split views black) and anything the root positions around one
|
|
311
|
+
inherits it rather than landing on the black shell.
|
|
312
|
+
|
|
313
|
+
No border: Figma draws one, but every view already brings its own edge — the
|
|
314
|
+
table its header rule, the split views their panel borders — so it only ever
|
|
315
|
+
read as a second outline around the first. */}
|
|
316
|
+
<div className="bg-background-presentation-form-base flex flex-1 overflow-hidden rounded-[16px]">
|
|
317
|
+
{/* Clip the scrollable surface to the parent radius minus its 1px border
|
|
318
|
+
(16 − 1). At the full 16px the opaque view background sits flush with the
|
|
319
|
+
outer edge and bleeds past it as a hairline on the straight sides. */}
|
|
320
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[15px]">
|
|
321
|
+
{/* Anything the root does not position itself — a `Filters` bar, a toolbar
|
|
322
|
+
of your own — sits above the view and *inside* the light surface, since
|
|
323
|
+
its controls are host-themed and would be stranded on the black shell. */}
|
|
324
|
+
{extras.length > 0 && (
|
|
325
|
+
<div className="bg-background-presentation-form-base shrink-0">{extras}</div>
|
|
326
|
+
)}
|
|
327
|
+
{/* A column, so the active view stretches to the full width. As a row it
|
|
328
|
+
would size each view to its content and leave the shell showing beside
|
|
329
|
+
a narrow one. */}
|
|
330
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-auto">{body}</div>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
</main>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{/* The rail is a sibling of the content, not a layer over it, so opening it narrows
|
|
337
|
+
the view rather than covering it. */}
|
|
338
|
+
{panelEl}
|
|
339
|
+
</div>
|
|
340
|
+
</FiltersContext.Provider>
|
|
341
|
+
</PanelContext.Provider>
|
|
342
|
+
</ViewContext.Provider>
|
|
343
|
+
</DataContext.Provider>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* DataViews — a compound, composition-based multi-view data surface. Author a screen as JSX:
|
|
349
|
+
*
|
|
350
|
+
* ```tsx
|
|
351
|
+
* <DataViews rows={rows} fields={fields} total={total} onQueryChange={setQuery}>
|
|
352
|
+
* <DataViews.Header title="Orders">
|
|
353
|
+
* <DataViews.ViewSwitch />
|
|
354
|
+
* <DataViews.Search />
|
|
355
|
+
* </DataViews.Header>
|
|
356
|
+
* <DataViews.Table />
|
|
357
|
+
* <DataViews.Board groups={groups} />
|
|
358
|
+
* </DataViews>
|
|
359
|
+
* ```
|
|
360
|
+
*
|
|
361
|
+
* A view exists **iff you render it** — there is no visibility map. DataViews is pure UI: it
|
|
362
|
+
* never filters, searches, sorts, groups or mutates. Hand it rows and it paints them; it hands
|
|
363
|
+
* you back what the user did so you can go and query.
|
|
364
|
+
*/
|
|
365
|
+
export const DataViews = Object.assign(DataViewsRoot, {
|
|
366
|
+
// shell
|
|
367
|
+
Header,
|
|
368
|
+
ViewSwitch,
|
|
369
|
+
Search,
|
|
370
|
+
Actions,
|
|
371
|
+
PanelToggle,
|
|
372
|
+
// panels
|
|
373
|
+
Panel,
|
|
374
|
+
Filters,
|
|
375
|
+
// views
|
|
376
|
+
Table: TableView,
|
|
377
|
+
Board: BoardView,
|
|
378
|
+
Inbox: InboxView,
|
|
379
|
+
Tree: TreeView,
|
|
380
|
+
Detail,
|
|
381
|
+
// states
|
|
382
|
+
// paging
|
|
383
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { fieldKindOf } from "../../FormBuilder";
|
|
5
|
+
import type { FilterFieldDescriptor } from "../../../utils/dataViews/types";
|
|
6
|
+
import { toName } from "./values";
|
|
7
|
+
import { Labelled } from "./labelled";
|
|
8
|
+
|
|
9
|
+
/** How a FormBuilder field's value becomes a filter. Fields not listed here are not filters. */
|
|
10
|
+
const AS_FILTER = {
|
|
11
|
+
text: { kind: "text" } as const,
|
|
12
|
+
choice: { kind: "choice", single: true } as const,
|
|
13
|
+
multiChoice: { kind: "choice", single: false } as const,
|
|
14
|
+
date: { kind: "date" } as const,
|
|
15
|
+
slider: { kind: "number" } as const,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Walk the children and describe every FormBuilder field among them.
|
|
20
|
+
*
|
|
21
|
+
* This is what `fields` used to be, except it is read from the controls you wrote rather than
|
|
22
|
+
* declared twice. The walk follows `props.children`, so a field stays visible through a plain
|
|
23
|
+
* wrapper; it cannot see through a component that builds the element itself, which is the same
|
|
24
|
+
* limitation the layout markers have.
|
|
25
|
+
*
|
|
26
|
+
* `Checkbox`, `Number`, `File` and friends have no filter meaning — a boolean is not a query and a
|
|
27
|
+
* file is not a value you can filter by — so they render but contribute nothing. `Filters.Custom`
|
|
28
|
+
* is the way to drive a filter no FormBuilder field covers.
|
|
29
|
+
*/
|
|
30
|
+
export function collectFilterFields(children: React.ReactNode): FilterFieldDescriptor[] {
|
|
31
|
+
const out: FilterFieldDescriptor[] = [];
|
|
32
|
+
const seen = new Set<string>();
|
|
33
|
+
|
|
34
|
+
const walk = (node: React.ReactNode) => {
|
|
35
|
+
if (Array.isArray(node)) {
|
|
36
|
+
node.forEach(walk);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!React.isValidElement(node)) return;
|
|
40
|
+
|
|
41
|
+
const props = node.props as {
|
|
42
|
+
name?: string;
|
|
43
|
+
label?: React.ReactNode;
|
|
44
|
+
min?: number;
|
|
45
|
+
max?: number;
|
|
46
|
+
children?: React.ReactNode;
|
|
47
|
+
};
|
|
48
|
+
const kind = fieldKindOf(node);
|
|
49
|
+
const as = kind ? AS_FILTER[kind as keyof typeof AS_FILTER] : undefined;
|
|
50
|
+
|
|
51
|
+
if (as && props.name && !seen.has(props.name)) {
|
|
52
|
+
seen.add(props.name);
|
|
53
|
+
out.push({
|
|
54
|
+
path: props.name,
|
|
55
|
+
label: typeof props.label === "string" ? props.label : undefined,
|
|
56
|
+
...as,
|
|
57
|
+
...(as.kind === "number" ? { min: props.min ?? 0, max: props.max ?? 100 } : null),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
walk(props.children);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
walk(children);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render the children, with two changes to every field.
|
|
70
|
+
*
|
|
71
|
+
* The `name` is escaped, because RHF reads `customer.name` as object nesting — the author writes
|
|
72
|
+
* the real path and `FilterState` stays keyed by it. Anything calling `setValue` on a filter has
|
|
73
|
+
* to use `toName(path)` for the same reason.
|
|
74
|
+
*
|
|
75
|
+
* The `label` moves out of the field and into `Labelled` above it: `"bare"` mode exists precisely
|
|
76
|
+
* to drop `FieldSection`'s label row, so a label left on the field would simply vanish.
|
|
77
|
+
*/
|
|
78
|
+
export function renderFields(children: React.ReactNode): React.ReactNode {
|
|
79
|
+
return React.Children.map(children, (child) => {
|
|
80
|
+
if (!React.isValidElement(child)) return child;
|
|
81
|
+
|
|
82
|
+
const props = child.props as { name?: string; label?: React.ReactNode; children?: React.ReactNode };
|
|
83
|
+
|
|
84
|
+
if (fieldKindOf(child) && props.name) {
|
|
85
|
+
return (
|
|
86
|
+
<Labelled label={props.label}>
|
|
87
|
+
{React.cloneElement(child, { name: toName(props.name), label: undefined } as never)}
|
|
88
|
+
</Labelled>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Not a field — recurse so a control inside a wrapper still gets its name escaped.
|
|
93
|
+
if (props.children) {
|
|
94
|
+
return React.cloneElement(child, {} as never, renderFields(props.children));
|
|
95
|
+
}
|
|
96
|
+
return child;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatPathLabel } from "../../../utils/dataViews/path";
|
|
4
|
+
import type { FilterValue } from "../../../utils/dataViews/types";
|
|
5
|
+
import { useDataViewsFilters } from "../context";
|
|
6
|
+
import { Labelled } from "./labelled";
|
|
7
|
+
import type { FilterCustomProps } from "../types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `Filters.Custom` — the escape hatch. Renders whatever you return against this filter's slot in
|
|
11
|
+
* `FilterState`, bypassing the form entirely.
|
|
12
|
+
*
|
|
13
|
+
* Use it when no FormBuilder field produces the value you need — a bucket picker, a map region, a
|
|
14
|
+
* tri-state toggle.
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <DataViews.Filters.Custom path="tags" render={({ value, setValue }) => (
|
|
18
|
+
* <MyTagPicker value={value as string[]} onChange={setValue} />
|
|
19
|
+
* )} />
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function Custom({ path, label, render }: FilterCustomProps) {
|
|
23
|
+
const { filters, setFilters } = useDataViewsFilters();
|
|
24
|
+
const text = label ?? formatPathLabel(path);
|
|
25
|
+
|
|
26
|
+
const setValue = (next: FilterValue | undefined) => {
|
|
27
|
+
const copy = { ...filters };
|
|
28
|
+
if (next === undefined) delete copy[path];
|
|
29
|
+
else copy[path] = next;
|
|
30
|
+
setFilters(copy);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return <Labelled label={text}>{render({ value: filters[path], setValue })}</Labelled>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, useMemo, useState } from "react";
|
|
4
|
+
import { cn } from "../../../utils/cn";
|
|
5
|
+
import { Button } from "../../Button";
|
|
6
|
+
import { ConclusionHeader } from "../../ConclusionHeader";
|
|
7
|
+
import { FormBuilder } from "../../FormBuilder";
|
|
8
|
+
import { CellContext } from "../../FormBuilder/context";
|
|
9
|
+
import { FiltersContext, useDataViewsFilters } from "../context";
|
|
10
|
+
import { collectFilterFields, renderFields } from "./children";
|
|
11
|
+
import { Custom } from "./custom";
|
|
12
|
+
import { Presets } from "./presets";
|
|
13
|
+
import { Summary } from "./summary";
|
|
14
|
+
import { Sync } from "./sync";
|
|
15
|
+
import { toFormValues } from "./values";
|
|
16
|
+
import type { FiltersProps } from "../types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `DataViews.Filters` — the filter controls, written as a form.
|
|
20
|
+
*
|
|
21
|
+
* Three things to be clear about.
|
|
22
|
+
*
|
|
23
|
+
* **It does not filter.** It collects what the user asked for and calls `onValueChange`. Going and
|
|
24
|
+
* fetching the matching rows is your job, almost certainly server-side. The rows on screen change
|
|
25
|
+
* when you hand back different `rows` — never before.
|
|
26
|
+
*
|
|
27
|
+
* **The children are FormBuilder fields.** Not a config array describing fields — the fields
|
|
28
|
+
* themselves, one JSX child each, exactly as any other form in this library is written. They
|
|
29
|
+
* render through `CellContext` in `"bare"` mode: no `FieldSection` label row, no table borders,
|
|
30
|
+
* errors as tooltips.
|
|
31
|
+
*
|
|
32
|
+
* **What each child means comes from the field, not from you.** `FormBuilder` stamps every field
|
|
33
|
+
* with a `FieldKind` — see `fieldKindOf` — so a `MultiSelect` becomes a list of values, a `Slider`
|
|
34
|
+
* a numeric range, a `DateRange` a date range. Nothing is inferred from the rows.
|
|
35
|
+
*
|
|
36
|
+
* There is no `value`/`onValueChange` here: the filters live in the root's query, and this reads
|
|
37
|
+
* and writes them through `useDataViewsFilters()`. What leaves the component is `onQueryChange`.
|
|
38
|
+
*
|
|
39
|
+
* ```tsx
|
|
40
|
+
* <DataViews.Filters>
|
|
41
|
+
* <FormBuilder.MultiSelect name="status" label="Status" options={STATUS} />
|
|
42
|
+
* <FormBuilder.Slider name="total" label="Total" range min={0} max={15000} step={100} />
|
|
43
|
+
* <DataViews.Filters.Presets for="total" items={[{ label: "Under $500", max: 500 }]} />
|
|
44
|
+
* </DataViews.Filters>
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
function FiltersRoot({
|
|
48
|
+
children,
|
|
49
|
+
title = "Filters",
|
|
50
|
+
description,
|
|
51
|
+
clearLabel = "Clear",
|
|
52
|
+
collapsible = true,
|
|
53
|
+
defaultOpen = true,
|
|
54
|
+
className,
|
|
55
|
+
}: FiltersProps) {
|
|
56
|
+
const { filters, setFilters } = useDataViewsFilters();
|
|
57
|
+
|
|
58
|
+
const fields = useMemo(() => collectFilterFields(children), [children]);
|
|
59
|
+
|
|
60
|
+
const value = useMemo(
|
|
61
|
+
() => ({ filters, setFilters, filterFields: fields }),
|
|
62
|
+
[filters, setFilters, fields],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const values = useMemo(() => toFormValues(filters, fields), [filters, fields]);
|
|
66
|
+
const active = Object.keys(filters).length > 0;
|
|
67
|
+
|
|
68
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
69
|
+
const bodyId = useId();
|
|
70
|
+
const isCollapsible = collapsible && title != null;
|
|
71
|
+
const shown = !isCollapsible || open;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<FiltersContext.Provider value={value}>
|
|
75
|
+
<div
|
|
76
|
+
className={cn(
|
|
77
|
+
"flex shrink-0 flex-col gap-3 px-4 py-3",
|
|
78
|
+
"border-border-presentation-global-primary border-b",
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{(title || active) && (
|
|
83
|
+
// `ConclusionHeader` renders a `<button>`, so Clear cannot live inside it — nesting a
|
|
84
|
+
// button in a button is invalid and swallows the inner click. They are siblings.
|
|
85
|
+
<div className="flex items-center gap-2">
|
|
86
|
+
{title != null &&
|
|
87
|
+
(isCollapsible ? (
|
|
88
|
+
<ConclusionHeader
|
|
89
|
+
label={title}
|
|
90
|
+
open={open}
|
|
91
|
+
onOpenChange={setOpen}
|
|
92
|
+
aria-controls={bodyId}
|
|
93
|
+
className="flex-1"
|
|
94
|
+
/>
|
|
95
|
+
) : (
|
|
96
|
+
// Reading the colour from a token rather than a `text-white` literal is what lets
|
|
97
|
+
// the same controls sit in the dark rail and in the light content area.
|
|
98
|
+
<h3 className="text-content-presentation-global-primary text-[18px] font-[510] leading-[1.32] tracking-[-0.01em]">
|
|
99
|
+
{title}
|
|
100
|
+
</h3>
|
|
101
|
+
))}
|
|
102
|
+
{active && (
|
|
103
|
+
<Button size="S" variant="BorderStyle" className="ms-auto" onClick={() => setFilters({})}>
|
|
104
|
+
{clearLabel}
|
|
105
|
+
</Button>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* Below the header *row*, not inside it — that row is a flex line holding the header and
|
|
111
|
+
Clear as siblings, and the description belongs to the section, not beside the button. */}
|
|
112
|
+
{description != null && (
|
|
113
|
+
<p className="typography-body-small-regular text-content-presentation-global-primary">
|
|
114
|
+
{description}
|
|
115
|
+
</p>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{/* `onSubmit` is required by FormBuilder but never reached — there is no submit button and
|
|
119
|
+
no Enter target. Filters emit as you touch them. */}
|
|
120
|
+
{/* `layout="bare"` because this form is embedded, not a page: FormBuilder's default
|
|
121
|
+
centres the fields at 1100px behind 48px gutters, which in a 260px rail leaves the
|
|
122
|
+
controls less room than their own minimum width and overflows them. */}
|
|
123
|
+
<FormBuilder
|
|
124
|
+
onSubmit={() => {}}
|
|
125
|
+
values={values}
|
|
126
|
+
fieldDirection="vertical"
|
|
127
|
+
layout="bare"
|
|
128
|
+
>
|
|
129
|
+
<Sync fields={fields} />
|
|
130
|
+
<CellContext.Provider value="bare">
|
|
131
|
+
{/* Same fold as `Panel.Section`: a 0fr→1fr grid row, and `inert` so a collapsed
|
|
132
|
+
filter's control leaves the tab order rather than staying focusable. */}
|
|
133
|
+
<div
|
|
134
|
+
id={bodyId}
|
|
135
|
+
inert={!shown}
|
|
136
|
+
aria-hidden={!shown}
|
|
137
|
+
className={cn(
|
|
138
|
+
"grid transition-[grid-template-rows,opacity] duration-200 ease-in-out",
|
|
139
|
+
"motion-reduce:transition-none",
|
|
140
|
+
shown ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
<div className="overflow-hidden">
|
|
144
|
+
<div className="flex flex-wrap items-start gap-3">{renderFields(children)}</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</CellContext.Provider>
|
|
148
|
+
</FormBuilder>
|
|
149
|
+
</div>
|
|
150
|
+
</FiltersContext.Provider>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The filter surface, assembled in one place. `Presets` and `Custom` are ours because FormBuilder
|
|
157
|
+
* has no field for them; everything else you write is a FormBuilder field.
|
|
158
|
+
*/
|
|
159
|
+
export const Filters = Object.assign(FiltersRoot, {
|
|
160
|
+
Presets,
|
|
161
|
+
Custom,
|
|
162
|
+
Summary,
|
|
163
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The filter surface. `filters.tsx` assembles the compound; `children.tsx` is the walk that reads
|
|
2
|
+
// your FormBuilder fields; `values.ts` is the pure mapping between `FilterState` and form values.
|
|
3
|
+
export { Filters } from "./filters";
|
|
4
|
+
export { collectFilterFields } from "./children";
|