torch-glare 2.1.1 → 2.1.2

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 (62) hide show
  1. package/apps/lib/components/BadgeField.tsx +2 -2
  2. package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
  3. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +416 -0
  4. package/apps/lib/components/DataViews/DataViewsHeader.tsx +126 -0
  5. package/apps/lib/components/DataViews/DataViewsLayout.tsx +300 -0
  6. package/apps/lib/components/DataViews/FilterPanel.tsx +324 -0
  7. package/apps/lib/components/DataViews/InboxView.tsx +514 -0
  8. package/apps/lib/components/DataViews/KanbanView.tsx +242 -0
  9. package/apps/lib/components/DataViews/PanelControls.tsx +80 -0
  10. package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
  11. package/apps/lib/components/DataViews/TableView.tsx +232 -0
  12. package/apps/lib/components/DataViews/TreeView.tsx +363 -0
  13. package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
  14. package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
  15. package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
  16. package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
  17. package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
  18. package/apps/lib/components/DataViews/index.ts +30 -0
  19. package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
  20. package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
  21. package/apps/lib/components/DataViews/types.ts +177 -0
  22. package/apps/lib/components/TreeFolder/TreeFolder.tsx +387 -0
  23. package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
  24. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +235 -0
  25. package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
  26. package/apps/lib/components/TreeFolder/icons.tsx +63 -0
  27. package/apps/lib/components/TreeFolder/index.ts +17 -0
  28. package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
  29. package/apps/lib/components/TreeFolder/types.ts +68 -0
  30. package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
  31. package/apps/lib/hooks/useDataViewsState.ts +169 -0
  32. package/apps/lib/hooks/useIsMobile.ts +21 -0
  33. package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
  34. package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
  35. package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
  36. package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
  37. package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
  38. package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
  39. package/dist/bin/index.js +3 -3
  40. package/dist/bin/index.js.map +1 -1
  41. package/dist/src/commands/add.d.ts.map +1 -1
  42. package/dist/src/commands/add.js +29 -6
  43. package/dist/src/commands/add.js.map +1 -1
  44. package/dist/src/commands/utils.d.ts.map +1 -1
  45. package/dist/src/commands/utils.js +22 -2
  46. package/dist/src/commands/utils.js.map +1 -1
  47. package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
  48. package/dist/src/shared/copyComponentsRecursively.js +8 -1
  49. package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
  50. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
  51. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
  52. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
  53. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
  54. package/docs/components/form-stepper.md +244 -0
  55. package/docs/components/stepper.md +215 -0
  56. package/docs/components/timeline.md +248 -0
  57. package/package.json +6 -6
  58. package/apps/lib/components/Charts-dev.tsx +0 -365
  59. package/apps/lib/components/Command-dev.tsx +0 -151
  60. package/apps/lib/components/IosDatePicker-dev.tsx +0 -341
  61. /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
  62. /package/docs/components/{tree-dropdown.md → tree-drop-down.md} +0 -0
@@ -0,0 +1,300 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { List, LayoutGrid, Inbox as InboxIcon, Network } from "lucide-react";
12
+ import type {
13
+ DynamicRecord,
14
+ DynamicColumnConfig,
15
+ DynamicFilterConfig,
16
+ FilterState,
17
+ FieldConfig,
18
+ InboxConfig,
19
+ TreeConfig,
20
+ ViewConfig,
21
+ ViewType,
22
+ ViewVisibility,
23
+ } from "./types";
24
+ import { TableView } from "./TableView";
25
+ import { KanbanView } from "./KanbanView";
26
+ import { InboxView } from "./InboxView";
27
+ import { TreeView } from "./TreeView";
28
+ import { DataViewsHeader, type DataViewsHeaderView } from "./DataViewsHeader";
29
+ import { DataViewsConfigPanel } from "./DataViewsConfigPanel";
30
+ import { useDataViewsState } from "../../hooks/useDataViewsState";
31
+ import { cn } from "../../utils/cn";
32
+ import type { Themes } from "../../utils/types";
33
+
34
+ export type DataViewsLayoutProps = {
35
+ data?: DynamicRecord[];
36
+ config?: Partial<ViewConfig>;
37
+ title?: string;
38
+ description?: string;
39
+
40
+ fields?: FieldConfig[];
41
+ inboxConfig?: InboxConfig;
42
+ treeConfig?: TreeConfig;
43
+ kanbanGroupBy?: string;
44
+
45
+ views?: ViewVisibility;
46
+
47
+ columns?: Partial<DynamicColumnConfig>[];
48
+ filters?: DynamicFilterConfig[];
49
+
50
+ filterState?: FilterState;
51
+ onFilterChange?: (filters: FilterState) => void;
52
+
53
+ showFilters?: boolean;
54
+ showSettings?: boolean;
55
+ showTitle?: boolean;
56
+
57
+ onAddNew?: () => void;
58
+ addNewLabel?: string;
59
+
60
+ className?: string;
61
+ theme?: Themes;
62
+ };
63
+
64
+ const VIEW_META: Record<
65
+ ViewType,
66
+ { label: string; icon: DataViewsHeaderView["icon"] }
67
+ > = {
68
+ table: { label: "List", icon: <List /> },
69
+ kanban: { label: "Board", icon: <LayoutGrid /> },
70
+ inbox: { label: "Inbox", icon: <InboxIcon /> },
71
+ tree: { label: "Tree", icon: <Network /> },
72
+ };
73
+
74
+ const VIEW_ORDER: ViewType[] = ["table", "kanban", "inbox", "tree"];
75
+
76
+ export const DataViewsLayout = forwardRef<HTMLDivElement, DataViewsLayoutProps>(
77
+ function DataViewsLayout(props, ref) {
78
+ const {
79
+ title = "Data Views",
80
+ data,
81
+ config: initialConfig,
82
+ fields,
83
+ inboxConfig,
84
+ treeConfig,
85
+ kanbanGroupBy,
86
+ views,
87
+ columns,
88
+ filters,
89
+ filterState: externalFilterState,
90
+ onFilterChange,
91
+ // `showFilters` is retained on the public API but filters now live in the
92
+ // right-side config rail rather than an inline per-view panel.
93
+ showSettings = true,
94
+ showTitle = true,
95
+ onAddNew,
96
+ addNewLabel,
97
+ className,
98
+ theme,
99
+ } = props;
100
+
101
+ const {
102
+ items,
103
+ flatItems,
104
+ resolvedFields,
105
+ detectedColumns,
106
+ config,
107
+ setConfig,
108
+ currentView,
109
+ setCurrentView,
110
+ filterState,
111
+ setFilterState,
112
+ onDataUpdate,
113
+ enabledViews,
114
+ } = useDataViewsState({
115
+ data,
116
+ fields,
117
+ columns,
118
+ config: initialConfig,
119
+ treeConfig,
120
+ views,
121
+ filterState: externalFilterState,
122
+ onFilterChange,
123
+ });
124
+
125
+ // `panelOpen` drives intent; `panelMounted` keeps the panel in the tree
126
+ // through the close animation before unmounting.
127
+ const [panelOpen, setPanelOpen] = useState(false);
128
+ const [panelMounted, setPanelMounted] = useState(false);
129
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
130
+
131
+ const openPanel = useCallback(() => {
132
+ if (closeTimer.current) {
133
+ clearTimeout(closeTimer.current);
134
+ closeTimer.current = null;
135
+ }
136
+ // Mount at width 0 first, then flip to open on the next frame so the
137
+ // width transition animates from 0 → 260px instead of snapping.
138
+ setPanelMounted(true);
139
+ requestAnimationFrame(() =>
140
+ requestAnimationFrame(() => setPanelOpen(true)),
141
+ );
142
+ }, []);
143
+
144
+ const closePanel = useCallback(() => {
145
+ setPanelOpen(false);
146
+ if (closeTimer.current) clearTimeout(closeTimer.current);
147
+ // Keep mounted through the width/fade animation (300ms) before unmounting.
148
+ closeTimer.current = setTimeout(() => setPanelMounted(false), 300);
149
+ }, []);
150
+
151
+ const togglePanel = useCallback(() => {
152
+ if (panelOpen) closePanel();
153
+ else openPanel();
154
+ }, [panelOpen, openPanel, closePanel]);
155
+
156
+ useEffect(() => {
157
+ return () => {
158
+ if (closeTimer.current) clearTimeout(closeTimer.current);
159
+ };
160
+ }, []);
161
+
162
+ const effectiveKanbanGroupBy = kanbanGroupBy ?? config.kanbanGroupBy;
163
+ // Filters now live in the right-side rail, not as an inline per-view panel.
164
+ const effectiveConfig: ViewConfig = { ...config, showFilters: false };
165
+
166
+ const headerViews = useMemo<DataViewsHeaderView[]>(
167
+ () =>
168
+ VIEW_ORDER.filter((v) => enabledViews[v]).map((v) => ({
169
+ id: v,
170
+ label: VIEW_META[v].label,
171
+ icon: VIEW_META[v].icon,
172
+ })),
173
+ [enabledViews],
174
+ );
175
+
176
+ return (
177
+ <div
178
+ ref={ref}
179
+ data-theme={theme}
180
+ className={cn(
181
+ // Shell is always black (matches Figma): the dark header and config
182
+ // rail sit on it; the Master Container is the white surface inside.
183
+ "flex h-screen gap-2 bg-black p-2 text-content-presentation-global-primary",
184
+ className,
185
+ )}
186
+ >
187
+ {/* Left column: header + content. Shrinks as the panel expands. */}
188
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
189
+ {showTitle && (
190
+ <DataViewsHeader
191
+ title={title}
192
+ views={headerViews}
193
+ currentView={currentView}
194
+ onViewChange={setCurrentView}
195
+ showSettings={showSettings}
196
+ settingsOpen={panelOpen}
197
+ onToggleSettings={togglePanel}
198
+ onAddNew={onAddNew}
199
+ addNewLabel={addNewLabel}
200
+ />
201
+ )}
202
+
203
+ <main className="flex min-h-0 flex-1 overflow-hidden ">
204
+ {/* Master Container — white card, 16px radius, #D4D4D4 hairline
205
+ border. Fixed surface (matches header chrome). */}
206
+ <div className="flex flex-1 overflow-hidden rounded-[16px] border border-border-presentation-global-primary ">
207
+ {/* Clip the scrollable surface to the parent radius MINUS the 1px
208
+ border (16 − 1 = 15px). Using the full 16px here let the
209
+ opaque view background sit flush with the parent's outer edge
210
+ and bleed past the border as a ~1px light line on the
211
+ left/right straight sides. */}
212
+ <div className="flex-1 overflow-auto rounded-[15px]">
213
+ {currentView === "table" && enabledViews.table && (
214
+ <TableView
215
+ data={flatItems}
216
+ columns={detectedColumns}
217
+ fields={resolvedFields}
218
+ config={effectiveConfig}
219
+ onDataUpdate={onDataUpdate}
220
+ onSortChange={(sortBy, sortOrder) =>
221
+ setConfig({ sortBy, sortOrder })
222
+ }
223
+ filters={filters}
224
+ filterState={filterState}
225
+ onFilterChange={setFilterState}
226
+ showFilters={false}
227
+ />
228
+ )}
229
+ {currentView === "kanban" && enabledViews.kanban && (
230
+ <KanbanView
231
+ data={flatItems}
232
+ columns={detectedColumns}
233
+ fields={resolvedFields}
234
+ config={effectiveConfig}
235
+ onDataUpdate={onDataUpdate}
236
+ groupByField={effectiveKanbanGroupBy}
237
+ />
238
+ )}
239
+ {currentView === "inbox" && enabledViews.inbox && (
240
+ <InboxView
241
+ data={flatItems}
242
+ columns={detectedColumns}
243
+ fields={resolvedFields}
244
+ inboxConfig={inboxConfig}
245
+ config={effectiveConfig}
246
+ onDataUpdate={onDataUpdate}
247
+ filters={filters}
248
+ filterState={filterState}
249
+ onFilterChange={setFilterState}
250
+ showFilters={false}
251
+ />
252
+ )}
253
+ {currentView === "tree" && enabledViews.tree && (
254
+ <TreeView
255
+ data={items}
256
+ columns={detectedColumns}
257
+ fields={resolvedFields}
258
+ treeConfig={treeConfig}
259
+ config={effectiveConfig}
260
+ onDataUpdate={onDataUpdate}
261
+ filters={filters}
262
+ filterState={filterState}
263
+ onFilterChange={setFilterState}
264
+ showFilters={false}
265
+ />
266
+ )}
267
+ </div>
268
+ </div>
269
+ </main>
270
+ </div>
271
+
272
+ {/* Right rail: full-height sibling of the [header + content] column, so
273
+ opening it pushes the header as well as the content. The wrapper
274
+ animates its width so the left column reflows in sync with the
275
+ panel's slide-in. */}
276
+ {showSettings && panelMounted && (
277
+ <div
278
+ className={cn(
279
+ "shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out",
280
+ panelOpen ? "w-[260px]" : "w-0",
281
+ )}
282
+ >
283
+ <DataViewsConfigPanel
284
+ state={panelOpen ? "open" : "closed"}
285
+ config={config}
286
+ onConfigChange={setConfig}
287
+ onClose={closePanel}
288
+ currentView={currentView}
289
+ fields={resolvedFields}
290
+ data={flatItems}
291
+ filterState={filterState}
292
+ onFilterChange={setFilterState}
293
+ filterConfig={filters}
294
+ />
295
+ </div>
296
+ )}
297
+ </div>
298
+ );
299
+ },
300
+ );
@@ -0,0 +1,324 @@
1
+ "use client"
2
+
3
+ import { Button } from "../Button"
4
+ import { Badge } from "../Badge"
5
+ import { X } from "lucide-react"
6
+ import { Checkbox } from "../Checkbox"
7
+ import { Divider } from "../Divider"
8
+ import { Label } from "../Label"
9
+ import type {
10
+ DynamicRecord,
11
+ DynamicFilterConfig,
12
+ FieldConfig,
13
+ FieldType,
14
+ FilterState,
15
+ FilterValue,
16
+ NumericRangeFilter,
17
+ DateRangeFilter,
18
+ } from "./types"
19
+ import { getByPath, formatPathLabel } from "../../utils/dataViews/pathUtils"
20
+ import {
21
+ computeNumericExtremes,
22
+ countActiveFilters,
23
+ inferStep,
24
+ isDateRange,
25
+ isNumericRange,
26
+ resolvePresets,
27
+ } from "../../utils/dataViews/rangeUtils"
28
+ import { RangeSliderWithInputs } from "./filters/RangeSliderWithInputs"
29
+ import { DateRangePopover } from "./filters/DateRangePopover"
30
+ import { PresetChips } from "./filters/PresetChips"
31
+ import { resolveBadgeVariant } from "./badgeAdapter"
32
+
33
+ type FilterPanelProps = {
34
+ data: DynamicRecord[]
35
+ fields: FieldConfig[]
36
+ filters: FilterState
37
+ onFilterChange: (path: string, value: FilterValue) => void
38
+ onClearAll: () => void
39
+ filterConfig?: DynamicFilterConfig[]
40
+ }
41
+
42
+ const NUMERIC_TYPES: FieldType[] = [
43
+ "number",
44
+ "number-format",
45
+ "currency",
46
+ "progress-bar",
47
+ "star-rating",
48
+ ]
49
+
50
+ const DATE_TYPES: FieldType[] = ["date", "date-format"]
51
+
52
+ type FilterKind = "categorical" | "numeric-range" | "date-range"
53
+
54
+ type Entry = {
55
+ path: string
56
+ label: string
57
+ kind: FilterKind
58
+ field?: FieldConfig
59
+ legacy?: DynamicFilterConfig
60
+ }
61
+
62
+ function buildFilterableEntries(
63
+ data: DynamicRecord[],
64
+ fields: FieldConfig[],
65
+ legacy?: DynamicFilterConfig[],
66
+ ): Entry[] {
67
+ const legacyByPath = new Map(
68
+ (legacy ?? []).filter((f) => f.enabled !== false).map((f) => [f.id, f]),
69
+ )
70
+
71
+ const entries: Entry[] = []
72
+ const seen = new Set<string>()
73
+
74
+ for (const f of fields) {
75
+ if (f.type === "hidden") continue
76
+ if (f.filterable === false) continue
77
+
78
+ const isExplicit = f.filterable === true
79
+ const isCategoricalAuto =
80
+ f.type === "enum-badge" ||
81
+ f.type === "boolean" ||
82
+ f.type === "badge-array" ||
83
+ f.type === "icon-text"
84
+ const isNumeric = f.type != null && NUMERIC_TYPES.includes(f.type)
85
+ const isDate = f.type != null && DATE_TYPES.includes(f.type)
86
+
87
+ let include = isExplicit || isCategoricalAuto
88
+
89
+ if (!include) {
90
+ if (f.type !== "text" && f.type !== undefined) continue
91
+ const unique = new Set<string>()
92
+ for (const item of data) {
93
+ const v = getByPath(item, f.path)
94
+ if (v == null) continue
95
+ unique.add(String(v))
96
+ if (unique.size > 10) break
97
+ }
98
+ include = unique.size > 0 && unique.size <= 10
99
+ }
100
+
101
+ if (!include) continue
102
+
103
+ const kind: FilterKind = isNumeric ? "numeric-range" : isDate ? "date-range" : "categorical"
104
+
105
+ entries.push({
106
+ path: f.path,
107
+ label: f.filterLabel ?? f.label ?? formatPathLabel(f.path),
108
+ kind,
109
+ field: f,
110
+ legacy: legacyByPath.get(f.path),
111
+ })
112
+ seen.add(f.path)
113
+ }
114
+
115
+ for (const lf of legacyByPath.values()) {
116
+ if (seen.has(lf.id)) continue
117
+ entries.push({
118
+ path: lf.id,
119
+ label: lf.label ?? formatPathLabel(lf.id),
120
+ kind: "categorical",
121
+ legacy: lf,
122
+ })
123
+ }
124
+
125
+ return entries
126
+ }
127
+
128
+ function getCategoricalOptions(
129
+ data: DynamicRecord[],
130
+ path: string,
131
+ field?: FieldConfig,
132
+ legacy?: DynamicFilterConfig,
133
+ ): string[] {
134
+ if (field?.filterOptions && field.filterOptions.length > 0) {
135
+ return normalizeOptions(field.filterOptions)
136
+ }
137
+ if (legacy?.options && legacy.options.length > 0) {
138
+ return normalizeOptions(legacy.options)
139
+ }
140
+ if (field?.variants) {
141
+ const fromMap = Object.keys(field.variants)
142
+ const fromData = collectUnique(data, path)
143
+ return Array.from(new Set([...fromMap, ...fromData]))
144
+ }
145
+ return collectUnique(data, path)
146
+ }
147
+
148
+ function normalizeOptions(opts: NonNullable<FieldConfig["filterOptions"]>): string[] {
149
+ if (opts.length === 0) return []
150
+ if (typeof opts[0] === "string") return opts as string[]
151
+ return (opts as { label: string; value: string }[]).map((o) => o.value)
152
+ }
153
+
154
+ function collectUnique(data: DynamicRecord[], path: string): string[] {
155
+ const set = new Set<string>()
156
+ for (const item of data) {
157
+ const v = getByPath(item, path)
158
+ if (v == null) continue
159
+ if (Array.isArray(v)) {
160
+ for (const x of v) set.add(String(x))
161
+ } else {
162
+ set.add(String(v))
163
+ }
164
+ }
165
+ return Array.from(set).sort()
166
+ }
167
+
168
+ export function FilterPanel({
169
+ data,
170
+ fields,
171
+ filters,
172
+ onFilterChange,
173
+ onClearAll,
174
+ filterConfig,
175
+ }: FilterPanelProps) {
176
+ const entries = buildFilterableEntries(data, fields, filterConfig)
177
+
178
+ const setFilter = (path: string, value: FilterValue) => {
179
+ onFilterChange(path, value)
180
+ const field = fields.find((f) => f.path === path)
181
+ field?.onFilterChange?.(value)
182
+ if (Array.isArray(value)) {
183
+ const legacy = filterConfig?.find((f) => f.id === path)
184
+ legacy?.onChange?.(value)
185
+ }
186
+ }
187
+
188
+ const toggleCategorical = (path: string, option: string) => {
189
+ const current = filters[path]
190
+ const arr = Array.isArray(current) ? current : []
191
+ const next = arr.includes(option) ? arr.filter((v) => v !== option) : [...arr, option]
192
+ setFilter(path, next)
193
+ }
194
+
195
+ const totalFilters = countActiveFilters(filters)
196
+
197
+ if (entries.length === 0) return null
198
+
199
+ const countBadge = resolveBadgeVariant("gray")
200
+
201
+ return (
202
+ <div className="w-64 border-r border-border-presentation-global-primary bg-background-presentation-body-overlay-primary p-4 overflow-y-auto">
203
+ <div className="flex items-center justify-between mb-4">
204
+ <div className="flex items-center gap-2">
205
+ <h3 className="font-semibold text-content-presentation-global-primary">Filters</h3>
206
+ {totalFilters > 0 && (
207
+ <Badge
208
+ {...countBadge}
209
+ label={String(totalFilters)}
210
+ className="h-5 min-w-[20px] rounded-full p-0 text-xs"
211
+ size="XS"
212
+
213
+ />
214
+ )}
215
+ </div>
216
+ {totalFilters > 0 && (
217
+ <Button variant="PrimeStyle" size="M" onClick={onClearAll} className="h-7 gap-1 text-xs">
218
+ <X className="h-3 w-3" />
219
+ Clear
220
+ </Button>
221
+ )}
222
+ </div>
223
+
224
+ <div className="space-y-4">
225
+ {entries.map((entry, index) => (
226
+ <div key={entry.path}>
227
+ {index > 0 && <Divider className="mb-4" />}
228
+ <div className="space-y-2">
229
+ <Label className="text-content-presentation-global-primary">{entry.label}</Label>
230
+ <FilterBody
231
+ entry={entry}
232
+ data={data}
233
+ value={filters[entry.path]}
234
+ onCategoricalToggle={(opt) => toggleCategorical(entry.path, opt)}
235
+ onSetFilter={(v) => setFilter(entry.path, v)}
236
+ />
237
+ </div>
238
+ </div>
239
+ ))}
240
+ </div>
241
+ </div>
242
+ )
243
+ }
244
+
245
+ function FilterBody({
246
+ entry,
247
+ data,
248
+ value,
249
+ onCategoricalToggle,
250
+ onSetFilter,
251
+ }: {
252
+ entry: Entry
253
+ data: DynamicRecord[]
254
+ value: FilterValue | undefined
255
+ onCategoricalToggle: (option: string) => void
256
+ onSetFilter: (next: FilterValue) => void
257
+ }) {
258
+ if (entry.kind === "numeric-range" && entry.field) {
259
+ const extremes = computeNumericExtremes(data, entry.path)
260
+ if (!extremes || extremes.min === extremes.max) {
261
+ return <div className="text-xs text-content-presentation-global-tertiary">No range to filter.</div>
262
+ }
263
+ const step = inferStep(entry.field, extremes)
264
+ const presets = resolvePresets(entry.field)
265
+ const numericValue: NumericRangeFilter | undefined = isNumericRange(value) ? value : undefined
266
+ return (
267
+ <div className="space-y-3">
268
+ {presets.length > 0 && (
269
+ <PresetChips presets={presets} current={value} onSelect={onSetFilter} />
270
+ )}
271
+ <RangeSliderWithInputs
272
+ field={entry.field}
273
+ extremes={extremes}
274
+ step={step}
275
+ value={numericValue}
276
+ onChange={onSetFilter}
277
+ />
278
+ </div>
279
+ )
280
+ }
281
+
282
+ if (entry.kind === "date-range" && entry.field) {
283
+ const presets = resolvePresets(entry.field)
284
+ const dateValue: DateRangeFilter | undefined = isDateRange(value) ? value : undefined
285
+ return (
286
+ <DateRangePopover
287
+ value={dateValue}
288
+ onChange={onSetFilter}
289
+ presets={presets.length > 0 ? presets : undefined}
290
+ />
291
+ )
292
+ }
293
+
294
+ const opts = getCategoricalOptions(data, entry.path, entry.field, entry.legacy)
295
+ const selected = Array.isArray(value) ? value : []
296
+ return (
297
+ <div className="space-y-2">
298
+ {opts.map((opt) => {
299
+ const isSelected = selected.includes(opt)
300
+ const variant = entry.field?.variants?.[opt]
301
+ const badgeProps = variant ? resolveBadgeVariant(variant) : null
302
+ return (
303
+ <div key={opt} className="flex items-center space-x-2">
304
+ <Checkbox
305
+ id={`${entry.path}-${opt}`}
306
+ checked={isSelected}
307
+ onCheckedChange={() => onCategoricalToggle(opt)}
308
+ />
309
+ <label
310
+ htmlFor={`${entry.path}-${opt}`}
311
+ className="text-sm text-content-presentation-global-primary cursor-pointer leading-none flex-1"
312
+ >
313
+ {entry.legacy?.render
314
+ ? entry.legacy.render(opt, isSelected)
315
+ : badgeProps
316
+ ? <Badge {...badgeProps} label={opt} size="XS" />
317
+ : opt}
318
+ </label>
319
+ </div>
320
+ )
321
+ })}
322
+ </div>
323
+ )
324
+ }