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.
Files changed (147) hide show
  1. package/apps/lib/components/DataViews/{badgeAdapter.ts → badge.ts} +2 -2
  2. package/apps/lib/components/DataViews/cell.tsx +324 -0
  3. package/apps/lib/components/DataViews/context.ts +144 -0
  4. package/apps/lib/components/DataViews/data-views.tsx +383 -0
  5. package/apps/lib/components/DataViews/filters/children.tsx +98 -0
  6. package/apps/lib/components/DataViews/filters/custom.tsx +34 -0
  7. package/apps/lib/components/DataViews/filters/filters.tsx +163 -0
  8. package/apps/lib/components/DataViews/filters/index.ts +4 -0
  9. package/apps/lib/components/DataViews/filters/labelled.tsx +20 -0
  10. package/apps/lib/components/DataViews/filters/presets.tsx +65 -0
  11. package/apps/lib/components/DataViews/filters/summary.tsx +65 -0
  12. package/apps/lib/components/DataViews/filters/sync.tsx +35 -0
  13. package/apps/lib/components/DataViews/filters/values.ts +173 -0
  14. package/apps/lib/components/DataViews/header.tsx +217 -0
  15. package/apps/lib/components/DataViews/hooks/index.ts +5 -0
  16. package/apps/lib/components/DataViews/hooks/useActiveRow.ts +22 -0
  17. package/apps/lib/components/DataViews/hooks/useControllable.ts +52 -0
  18. package/apps/lib/components/DataViews/index.ts +74 -26
  19. package/apps/lib/components/DataViews/panel/columns.tsx +153 -0
  20. package/apps/lib/components/DataViews/panel/controls.tsx +106 -0
  21. package/apps/lib/components/DataViews/panel/index.ts +3 -0
  22. package/apps/lib/components/DataViews/panel/panel.tsx +164 -0
  23. package/apps/lib/components/DataViews/panel/saved-views.tsx +67 -0
  24. package/apps/lib/components/DataViews/panel/section.tsx +79 -0
  25. package/apps/lib/components/DataViews/panel/sort.tsx +42 -0
  26. package/apps/lib/components/DataViews/panel/tab.tsx +31 -0
  27. package/apps/lib/components/DataViews/slots.ts +63 -0
  28. package/apps/lib/components/DataViews/states.tsx +38 -0
  29. package/apps/lib/components/DataViews/types.ts +485 -178
  30. package/apps/lib/components/DataViews/views/board-view.tsx +379 -0
  31. package/apps/lib/components/DataViews/views/card-rows.tsx +36 -0
  32. package/apps/lib/components/DataViews/views/inbox-view.tsx +257 -0
  33. package/apps/lib/components/DataViews/views/pane-views.tsx +192 -0
  34. package/apps/lib/components/DataViews/views/table-view.tsx +426 -0
  35. package/apps/lib/components/DataViews/views/tree-view.tsx +365 -0
  36. package/apps/lib/components/FormBuilder/context.ts +20 -6
  37. package/apps/lib/components/FormBuilder/field-kind.ts +28 -0
  38. package/apps/lib/components/FormBuilder/fields/DateField.tsx +3 -3
  39. package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +7 -6
  40. package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +30 -4
  41. package/apps/lib/components/FormBuilder/fields/SelectField.tsx +7 -7
  42. package/apps/lib/components/FormBuilder/fields/TableField.tsx +80 -52
  43. package/apps/lib/components/FormBuilder/fields/TextField.tsx +9 -9
  44. package/apps/lib/components/FormBuilder/form-builder.tsx +66 -6
  45. package/apps/lib/components/FormBuilder/index.ts +3 -1
  46. package/apps/lib/components/FormBuilder/types.ts +40 -0
  47. package/apps/lib/components/Input.tsx +3 -0
  48. package/apps/lib/components/SearchableTable.tsx +5 -4
  49. package/apps/lib/components/SectionBlock.tsx +58 -11
  50. package/apps/lib/components/Select.tsx +3 -1
  51. package/apps/lib/components/TabSwitch.tsx +16 -4
  52. package/apps/lib/components/Table.tsx +265 -67
  53. package/apps/lib/components/TreeFolder/TreeFolder.tsx +6 -3
  54. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +16 -14
  55. package/apps/lib/components/TreeFolder/index.ts +1 -1
  56. package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +70 -207
  57. package/apps/lib/hooks/useDragDrop.tsx +365 -0
  58. package/apps/lib/hooks/useInfiniteScroll.ts +108 -0
  59. package/apps/lib/registry.json +159 -4
  60. package/apps/lib/tsconfig.tsbuildinfo +1 -1
  61. package/apps/lib/utils/dataViews/path.ts +67 -0
  62. package/apps/lib/utils/dataViews/query.ts +73 -0
  63. package/apps/lib/utils/dataViews/types.ts +187 -0
  64. package/docs/components/breadcrumb.md +1 -1
  65. package/docs/components/button-group.md +1 -1
  66. package/docs/components/button.md +1 -1
  67. package/docs/components/card.md +1 -1
  68. package/docs/components/checkbox.md +1 -1
  69. package/docs/components/data-views/backend-response.md +324 -0
  70. package/docs/components/data-views/examples/a11y-rtl.md +250 -0
  71. package/docs/components/data-views/examples/api-orders-route.md +130 -0
  72. package/docs/components/data-views/examples/fields.md +362 -0
  73. package/docs/components/data-views/examples/filters.md +308 -0
  74. package/docs/components/data-views/examples/inbox-routing.md +218 -0
  75. package/docs/components/data-views/examples/index.md +29 -0
  76. package/docs/components/data-views/examples/overview.md +244 -0
  77. package/docs/components/data-views/examples/panel.md +212 -0
  78. package/docs/components/data-views/examples/scale.md +231 -0
  79. package/docs/components/data-views/examples/server-side.md +210 -0
  80. package/docs/components/data-views/examples/state.md +250 -0
  81. package/docs/components/data-views/examples/tree-custom.md +388 -0
  82. package/docs/components/data-views/examples/view-registry.md +313 -0
  83. package/docs/components/data-views/examples/views.md +534 -0
  84. package/docs/components/data-views/guide.md +405 -0
  85. package/docs/components/data-views/index.md +1504 -0
  86. package/docs/components/data-views/migration.md +79 -0
  87. package/docs/components/date-picker.md +0 -1
  88. package/docs/components/form-builder.md +19 -8
  89. package/docs/components/form-renderer.md +2 -1
  90. package/docs/components/form.md +1 -1
  91. package/docs/components/input-field.md +1 -1
  92. package/docs/components/input-otp.md +1 -1
  93. package/docs/components/input.md +1 -1
  94. package/docs/components/labeled-check-box.md +1 -1
  95. package/docs/components/labeled-radio.md +1 -1
  96. package/docs/components/radio-card.md +1 -1
  97. package/docs/components/radio.md +1 -1
  98. package/docs/components/search-field.md +1 -1
  99. package/docs/components/section-block.md +79 -3
  100. package/docs/components/select.md +1 -1
  101. package/docs/components/simple-select.md +1 -1
  102. package/docs/components/switch.md +1 -1
  103. package/docs/components/tab-switch.md +1 -1
  104. package/docs/components/table.md +45 -8
  105. package/docs/components/text-editor.md +1 -1
  106. package/docs/components/textarea.md +1 -1
  107. package/docs/components/toggle-button.md +1 -1
  108. package/docs/components/toggle.md +1 -1
  109. package/docs/components/tree-folder.md +110 -0
  110. package/docs/how-to/forms-with-form-builder.md +6 -4
  111. package/docs/reference/components.md +16 -6
  112. package/docs/tutorials/component-composition.md +11 -13
  113. package/package.json +3 -2
  114. package/apps/lib/components/DataViews/DataViewRadio.tsx +0 -49
  115. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +0 -393
  116. package/apps/lib/components/DataViews/DataViewsHeader.tsx +0 -207
  117. package/apps/lib/components/DataViews/DataViewsLayout.tsx +0 -332
  118. package/apps/lib/components/DataViews/FilterPanel.tsx +0 -493
  119. package/apps/lib/components/DataViews/HeaderSearch.tsx +0 -93
  120. package/apps/lib/components/DataViews/InboxView.tsx +0 -463
  121. package/apps/lib/components/DataViews/InboxViewCard.tsx +0 -127
  122. package/apps/lib/components/DataViews/KanbanView.tsx +0 -336
  123. package/apps/lib/components/DataViews/PanelControls.tsx +0 -39
  124. package/apps/lib/components/DataViews/SettingsPanel.tsx +0 -279
  125. package/apps/lib/components/DataViews/TableView.tsx +0 -212
  126. package/apps/lib/components/DataViews/TreeView.tsx +0 -364
  127. package/apps/lib/components/DataViews/fieldRenderers.tsx +0 -299
  128. package/apps/lib/components/DataViews/filters/DatePickerRangeFilter.tsx +0 -87
  129. package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +0 -120
  130. package/apps/lib/components/DataViews/filters/PresetChips.tsx +0 -45
  131. package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +0 -165
  132. package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +0 -50
  133. package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +0 -74
  134. package/apps/lib/hooks/useDataViewsState.ts +0 -175
  135. package/apps/lib/utils/dataViews/columnUtils.ts +0 -132
  136. package/apps/lib/utils/dataViews/fieldUtils.ts +0 -197
  137. package/apps/lib/utils/dataViews/nestedDataUtils.tsx +0 -371
  138. package/apps/lib/utils/dataViews/pathUtils.ts +0 -139
  139. package/apps/lib/utils/dataViews/rangeUtils.ts +0 -234
  140. package/apps/lib/utils/dataViews/treeUtils.ts +0 -396
  141. package/docs/components/data-views-config-panel.md +0 -208
  142. package/docs/components/data-views-layout.md +0 -291
  143. package/docs/components/inbox-view.md +0 -170
  144. package/docs/components/kanban-view.md +0 -135
  145. package/docs/components/table-view.md +0 -141
  146. package/docs/components/tree-view.md +0 -147
  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";