torch-glare 2.1.1 → 2.1.3
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/Avatar.tsx +1 -1
- package/apps/lib/components/BadgeField.tsx +2 -2
- package/apps/lib/components/Card.tsx +68 -54
- package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
- package/apps/lib/components/DataViews/DataViewRadio.tsx +47 -0
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +427 -0
- package/apps/lib/components/DataViews/DataViewsHeader.tsx +228 -0
- package/apps/lib/components/DataViews/DataViewsLayout.tsx +330 -0
- package/apps/lib/components/DataViews/FilterPanel.tsx +469 -0
- package/apps/lib/components/DataViews/HeaderSearch.tsx +97 -0
- package/apps/lib/components/DataViews/InboxView.tsx +495 -0
- package/apps/lib/components/DataViews/InboxViewCard.tsx +136 -0
- package/apps/lib/components/DataViews/KanbanView.tsx +353 -0
- package/apps/lib/components/DataViews/PanelControls.tsx +49 -0
- package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
- package/apps/lib/components/DataViews/TableView.tsx +232 -0
- package/apps/lib/components/DataViews/TreeView.tsx +392 -0
- package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
- package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
- package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
- package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
- package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
- package/apps/lib/components/DataViews/index.ts +36 -0
- package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
- package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
- package/apps/lib/components/DataViews/types.ts +206 -0
- package/apps/lib/components/Radio.tsx +18 -21
- package/apps/lib/components/Switch.tsx +3 -1
- package/apps/lib/components/Table.tsx +1 -1
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +410 -0
- package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
- package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +363 -0
- package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
- package/apps/lib/components/TreeFolder/icons.tsx +63 -0
- package/apps/lib/components/TreeFolder/index.ts +17 -0
- package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
- package/apps/lib/components/TreeFolder/types.ts +77 -0
- package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
- package/apps/lib/hooks/useDataViewsState.ts +169 -0
- package/apps/lib/hooks/useIsMobile.ts +21 -0
- package/apps/lib/layouts/DataViewCard.tsx +76 -0
- package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
- package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
- package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
- package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
- package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
- package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
- package/dist/bin/index.js +3 -3
- package/dist/bin/index.js.map +1 -1
- package/dist/src/commands/add.d.ts.map +1 -1
- package/dist/src/commands/add.js +29 -6
- package/dist/src/commands/add.js.map +1 -1
- package/dist/src/commands/utils.d.ts.map +1 -1
- package/dist/src/commands/utils.js +22 -2
- package/dist/src/commands/utils.js.map +1 -1
- package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
- package/dist/src/shared/copyComponentsRecursively.js +17 -2
- package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
- package/docs/components/data-views-config-panel.md +204 -0
- package/docs/components/data-views-layout.md +270 -0
- package/docs/components/form-stepper.md +244 -0
- package/docs/components/stepper.md +215 -0
- package/docs/components/timeline.md +248 -0
- package/package.json +6 -6
- package/apps/lib/components/Charts-dev.tsx +0 -365
- package/apps/lib/components/Command-dev.tsx +0 -151
- package/apps/lib/components/IosDatePicker-dev.tsx +0 -341
- /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
- /package/docs/components/{tree-dropdown.md → tree-drop-down.md} +0 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Fragment, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
X,
|
|
6
|
+
Settings as SettingsIcon,
|
|
7
|
+
Filter as FilterIcon,
|
|
8
|
+
Plus,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import type {
|
|
11
|
+
ViewConfig,
|
|
12
|
+
ViewType,
|
|
13
|
+
FieldConfig,
|
|
14
|
+
DynamicRecord,
|
|
15
|
+
DynamicFilterConfig,
|
|
16
|
+
FilterState,
|
|
17
|
+
FilterValue,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
import { RadioGroup } from "../Radio";
|
|
21
|
+
import { FilterPanel } from "./FilterPanel";
|
|
22
|
+
import { RadioRow, DataViewsSwitch } from "./PanelControls";
|
|
23
|
+
import { cn } from "../../utils/cn";
|
|
24
|
+
|
|
25
|
+
type ConfigTab = "config" | "filters";
|
|
26
|
+
|
|
27
|
+
type SavedView = { id: string; label: string };
|
|
28
|
+
|
|
29
|
+
export type DataViewsConfigPanelProps = {
|
|
30
|
+
config: ViewConfig;
|
|
31
|
+
onConfigChange: (config: Partial<ViewConfig>) => void;
|
|
32
|
+
onClose: () => void;
|
|
33
|
+
currentView: ViewType;
|
|
34
|
+
fields: FieldConfig[];
|
|
35
|
+
|
|
36
|
+
// Filters tab
|
|
37
|
+
data: DynamicRecord[];
|
|
38
|
+
filterState: FilterState;
|
|
39
|
+
onFilterChange: (filters: FilterState) => void;
|
|
40
|
+
filterConfig?: DynamicFilterConfig[];
|
|
41
|
+
|
|
42
|
+
// Saved views (presentational shell — wire to persistence when available)
|
|
43
|
+
savedViews?: SavedView[];
|
|
44
|
+
activeSavedView?: string;
|
|
45
|
+
onSavedViewChange?: (id: string) => void;
|
|
46
|
+
onSaveNewView?: () => void;
|
|
47
|
+
|
|
48
|
+
// Animation: drives slide-in/out. Parent keeps the panel mounted through
|
|
49
|
+
// the close animation, then unmounts.
|
|
50
|
+
state?: "open" | "closed";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const DEFAULT_SAVED_VIEWS: SavedView[] = [
|
|
54
|
+
{ id: "default", label: "Default View" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function SectionHeader({
|
|
58
|
+
title,
|
|
59
|
+
action,
|
|
60
|
+
}: {
|
|
61
|
+
title: string;
|
|
62
|
+
action?: React.ReactNode;
|
|
63
|
+
}) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex items-center justify-between">
|
|
66
|
+
<h3 className="text-[18px] font-[510] leading-[1.32] tracking-[-0.01em] text-white">
|
|
67
|
+
{title}
|
|
68
|
+
</h3>
|
|
69
|
+
{action}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 2×3 dot drag handle, matching the Figma SB-Column-Item grip (16×16 box,
|
|
75
|
+
* compact ~1.5px dots, tight spacing — drawn as an SVG for pixel accuracy). */
|
|
76
|
+
function GripDots() {
|
|
77
|
+
return (
|
|
78
|
+
<svg
|
|
79
|
+
aria-hidden
|
|
80
|
+
width="16"
|
|
81
|
+
height="16"
|
|
82
|
+
viewBox="0 0 16 16"
|
|
83
|
+
// Panel is always-dark chrome (like the hardcoded white label text):
|
|
84
|
+
// the grip stays white-on-dark regardless of host theme.
|
|
85
|
+
className="text-white/60"
|
|
86
|
+
fill="currentColor"
|
|
87
|
+
>
|
|
88
|
+
{[5.33, 9.33].flatMap((cx) =>
|
|
89
|
+
[3.33, 8, 12.67].map((cy) => (
|
|
90
|
+
<circle key={`${cx}-${cy}`} cx={cx} cy={cy} r="1" />
|
|
91
|
+
)),
|
|
92
|
+
)}
|
|
93
|
+
</svg>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 2px blue insertion line shown between rows during a drag-reorder. */
|
|
98
|
+
function DropLine() {
|
|
99
|
+
return (
|
|
100
|
+
<div className="pointer-events-none relative h-0">
|
|
101
|
+
<div className="absolute -top-[1px] left-0 right-0 h-[2px] rounded-full bg-[#005ECC]" />
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function DataViewsConfigPanel(props: DataViewsConfigPanelProps) {
|
|
107
|
+
const {
|
|
108
|
+
config,
|
|
109
|
+
onConfigChange,
|
|
110
|
+
onClose,
|
|
111
|
+
fields,
|
|
112
|
+
data,
|
|
113
|
+
filterState,
|
|
114
|
+
onFilterChange,
|
|
115
|
+
filterConfig,
|
|
116
|
+
savedViews = DEFAULT_SAVED_VIEWS,
|
|
117
|
+
activeSavedView,
|
|
118
|
+
onSavedViewChange,
|
|
119
|
+
onSaveNewView,
|
|
120
|
+
state = "open",
|
|
121
|
+
} = props;
|
|
122
|
+
|
|
123
|
+
const [tab, setTab] = useState<ConfigTab>("config");
|
|
124
|
+
|
|
125
|
+
// Saved View is controlled by the parent when `activeSavedView` /
|
|
126
|
+
// `onSavedViewChange` are supplied; otherwise fall back to local state so the
|
|
127
|
+
// radios are still interactive (DataViewsLayout doesn't thread these props
|
|
128
|
+
// yet — without this the selection would snap back every click).
|
|
129
|
+
const [internalSavedView, setInternalSavedView] = useState(
|
|
130
|
+
() => savedViews[0]?.id,
|
|
131
|
+
);
|
|
132
|
+
const selectedSavedView = activeSavedView ?? internalSavedView;
|
|
133
|
+
const handleSavedViewChange = (id: string) => {
|
|
134
|
+
if (onSavedViewChange) onSavedViewChange(id);
|
|
135
|
+
else setInternalSavedView(id);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const visibleFields = useMemo(
|
|
139
|
+
() => fields.filter((f) => f.type !== "hidden"),
|
|
140
|
+
[fields],
|
|
141
|
+
);
|
|
142
|
+
const visiblePaths = useMemo(
|
|
143
|
+
() => new Set(visibleFields.map((f) => f.path)),
|
|
144
|
+
[visibleFields],
|
|
145
|
+
);
|
|
146
|
+
const fieldByPath = useMemo(
|
|
147
|
+
() => new Map(visibleFields.map((f) => [f.path, f])),
|
|
148
|
+
[visibleFields],
|
|
149
|
+
);
|
|
150
|
+
const orderedColumns = useMemo(
|
|
151
|
+
() =>
|
|
152
|
+
[...config.tableColumns]
|
|
153
|
+
.filter((c) => visiblePaths.has(c.id))
|
|
154
|
+
.sort((a, b) => a.order - b.order),
|
|
155
|
+
[config.tableColumns, visiblePaths],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const toggleColumnVisibility = (path: string) => {
|
|
159
|
+
const next = config.tableColumns.map((c) =>
|
|
160
|
+
c.id === path ? { ...c, visible: !c.visible } : c,
|
|
161
|
+
);
|
|
162
|
+
onConfigChange({ tableColumns: next });
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const [dragPath, setDragPath] = useState<string | null>(null);
|
|
166
|
+
// Insertion slot in the ordered list: 0 means before the first row, N means
|
|
167
|
+
// after the last row (count). Single source of truth — there is exactly one
|
|
168
|
+
// indicator at a time, so no double-line ambiguity between adjacent rows.
|
|
169
|
+
const [dropSlot, setDropSlot] = useState<number | null>(null);
|
|
170
|
+
|
|
171
|
+
const reorderColumnToSlot = (sourcePath: string, slot: number) => {
|
|
172
|
+
const ids = orderedColumns.map((c) => c.id);
|
|
173
|
+
const from = ids.indexOf(sourcePath);
|
|
174
|
+
if (from === -1) return;
|
|
175
|
+
// Dropping into the same logical position (before or after itself) is a no-op.
|
|
176
|
+
if (slot === from || slot === from + 1) return;
|
|
177
|
+
const reordered = [...ids];
|
|
178
|
+
reordered.splice(from, 1);
|
|
179
|
+
// After removal, indices shift left by 1 for any slot beyond `from`.
|
|
180
|
+
const insertAt = slot > from ? slot - 1 : slot;
|
|
181
|
+
reordered.splice(insertAt, 0, sourcePath);
|
|
182
|
+
const orderByPath = new Map(reordered.map((id, i) => [id, i]));
|
|
183
|
+
const next = config.tableColumns.map((c) => {
|
|
184
|
+
const newOrder = orderByPath.get(c.id);
|
|
185
|
+
return newOrder == null ? c : { ...c, order: newOrder };
|
|
186
|
+
});
|
|
187
|
+
onConfigChange({ tableColumns: next });
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const sortableColumns = orderedColumns;
|
|
191
|
+
|
|
192
|
+
const tabBtn = (id: ConfigTab, icon: React.ReactNode, label: string) => {
|
|
193
|
+
const active = tab === id;
|
|
194
|
+
return (
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
aria-pressed={active}
|
|
198
|
+
onClick={() => setTab(id)}
|
|
199
|
+
className={cn(
|
|
200
|
+
"flex h-6 flex-1 items-center justify-center gap-1 rounded-[8px] px-3 text-[14px] font-[510] leading-none transition-all duration-200 ease-in-out",
|
|
201
|
+
active
|
|
202
|
+
? "bg-white text-black shadow-[0_0_10px_2px_rgba(0,0,0,0.25)]"
|
|
203
|
+
: "bg-transparent text-white hover:bg-white/5",
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
<span className="flex h-[14px] w-[14px] items-center justify-center [&_svg]:h-[14px] [&_svg]:w-[14px]">
|
|
207
|
+
{icon}
|
|
208
|
+
</span>
|
|
209
|
+
{label}
|
|
210
|
+
</button>
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div
|
|
216
|
+
data-state={state}
|
|
217
|
+
// Panel is always dark (Figma `Cun` = #000000). data-theme="dark" makes
|
|
218
|
+
// child themed components (Button, Switch, Radio, FilterPanel) resolve
|
|
219
|
+
// dark tokens even when the host app runs in default/light theme.
|
|
220
|
+
data-theme="dark"
|
|
221
|
+
className={cn(
|
|
222
|
+
"flex h-full w-[260px] flex-col overflow-hidden rounded-[16px] bg-black",
|
|
223
|
+
"transition-opacity duration-200 ease-in-out",
|
|
224
|
+
state === "open" ? "opacity-100" : "opacity-0",
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
227
|
+
{/* Header: tab switcher + close */}
|
|
228
|
+
<div className="flex items-center gap-2 px-3 py-3">
|
|
229
|
+
<div className="flex flex-1 items-center gap-[2px] rounded-[10px] bg-[#252729] p-[2px] shadow-[inset_0_0_5px_0_rgba(0,0,0,0.16)]">
|
|
230
|
+
{tabBtn("config", <SettingsIcon />, "Config.")}
|
|
231
|
+
{tabBtn("filters", <FilterIcon />, "Filters")}
|
|
232
|
+
</div>
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={onClose}
|
|
236
|
+
aria-label="Close panel"
|
|
237
|
+
className="flex h-7 w-7 items-center justify-center rounded-[8px] bg-white/[0.15] text-white transition-colors hover:bg-white/25"
|
|
238
|
+
>
|
|
239
|
+
<X className="h-[18px] w-[18px]" />
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div className="h-px w-full bg-[#2C2D2E]" />
|
|
244
|
+
|
|
245
|
+
<div className="flex-1 overflow-y-auto">
|
|
246
|
+
{tab === "config" ? (
|
|
247
|
+
<div className="flex flex-col gap-6 px-3 py-4">
|
|
248
|
+
{/* Saved View */}
|
|
249
|
+
<div className="space-y-3">
|
|
250
|
+
<SectionHeader title="Saved View" />
|
|
251
|
+
<RadioGroup
|
|
252
|
+
value={selectedSavedView}
|
|
253
|
+
onValueChange={handleSavedViewChange}
|
|
254
|
+
className="flex flex-col gap-1 space-y-0 rounded-[12px] bg-[#1C1D1F] p-1"
|
|
255
|
+
>
|
|
256
|
+
{savedViews.map((sv, i) => (
|
|
257
|
+
<div key={sv.id}>
|
|
258
|
+
{/* Divider spans edge-to-edge (Figma: no horizontal
|
|
259
|
+
inset). */}
|
|
260
|
+
{i > 0 && <div className="h-px bg-[#2C2D2E]" />}
|
|
261
|
+
<RadioRow value={sv.id} label={sv.label} />
|
|
262
|
+
</div>
|
|
263
|
+
))}
|
|
264
|
+
</RadioGroup>
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
onClick={onSaveNewView}
|
|
268
|
+
className="flex w-full items-center justify-center gap-1.5 rounded-[4px] bg-white/[0.15] px-1.5 py-0.5 text-[12px] font-[510] text-white transition-colors hover:bg-white/25"
|
|
269
|
+
>
|
|
270
|
+
<Plus className="h-3 w-3" />
|
|
271
|
+
Save a New View
|
|
272
|
+
</button>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div className="h-px w-full bg-[#2C2D2E]" />
|
|
276
|
+
|
|
277
|
+
{/* Table Columns */}
|
|
278
|
+
<div className="space-y-3">
|
|
279
|
+
<SectionHeader title="Table Columns" />
|
|
280
|
+
<p className="text-[12px] leading-[1.475] text-content-presentation-global-tertiary">
|
|
281
|
+
Show or hide columns in table view
|
|
282
|
+
</p>
|
|
283
|
+
{orderedColumns.length === 0 ? (
|
|
284
|
+
<p className="text-xs text-content-presentation-global-tertiary">
|
|
285
|
+
No fields detected.
|
|
286
|
+
</p>
|
|
287
|
+
) : (
|
|
288
|
+
<div data-theme="dark" className="flex flex-col gap-2">
|
|
289
|
+
{orderedColumns.map((col, index) => {
|
|
290
|
+
const field = fieldByPath.get(col.id);
|
|
291
|
+
const isDragging = dragPath === col.id;
|
|
292
|
+
// Slot for the cursor on this row: top half = insert at
|
|
293
|
+
// `index` (before this row); bottom half = `index + 1`
|
|
294
|
+
// (after this row, which is the SAME slot as "before next
|
|
295
|
+
// row" — the single source of truth avoids the old
|
|
296
|
+
// double-line problem in the gap between rows).
|
|
297
|
+
return (
|
|
298
|
+
<div key={col.id}>
|
|
299
|
+
{dropSlot === index && dragPath && <DropLine />}
|
|
300
|
+
<div
|
|
301
|
+
draggable
|
|
302
|
+
onDragStart={(e) => {
|
|
303
|
+
setDragPath(col.id);
|
|
304
|
+
e.dataTransfer.effectAllowed = "move";
|
|
305
|
+
e.dataTransfer.setData("text/plain", col.id);
|
|
306
|
+
}}
|
|
307
|
+
onDragOver={(e) => {
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
e.dataTransfer.dropEffect = "move";
|
|
310
|
+
const rect =
|
|
311
|
+
e.currentTarget.getBoundingClientRect();
|
|
312
|
+
const before =
|
|
313
|
+
e.clientY < rect.top + rect.height / 2;
|
|
314
|
+
const slot = before ? index : index + 1;
|
|
315
|
+
if (dropSlot !== slot) setDropSlot(slot);
|
|
316
|
+
}}
|
|
317
|
+
onDrop={(e) => {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
if (dragPath && dropSlot != null)
|
|
320
|
+
reorderColumnToSlot(dragPath, dropSlot);
|
|
321
|
+
setDragPath(null);
|
|
322
|
+
setDropSlot(null);
|
|
323
|
+
}}
|
|
324
|
+
onDragEnd={() => {
|
|
325
|
+
setDragPath(null);
|
|
326
|
+
setDropSlot(null);
|
|
327
|
+
}}
|
|
328
|
+
className={cn(
|
|
329
|
+
// SB-Column-Item: standalone #1C1D1F pill, #252729
|
|
330
|
+
// border. Figma container spec: 8px radius, 8.8px
|
|
331
|
+
// padding, 8px gap between grip / label / switch.
|
|
332
|
+
"flex items-center gap-2 rounded-e-[99px] rounded-s-[60px] border border-[#252729] bg-[#1C1D1F] p-[8.8px] transition-colors cursor-grab active:cursor-grabbing",
|
|
333
|
+
isDragging ? "opacity-50" : "hover:bg-[#252729]",
|
|
334
|
+
)}
|
|
335
|
+
>
|
|
336
|
+
<span className="flex shrink-0 items-center justify-center">
|
|
337
|
+
<GripDots />
|
|
338
|
+
</span>
|
|
339
|
+
<span className="flex-1 text-[14px] text-white">
|
|
340
|
+
{col.label || field?.label || col.id}
|
|
341
|
+
</span>
|
|
342
|
+
<span className="flex shrink-0 items-center">
|
|
343
|
+
<DataViewsSwitch
|
|
344
|
+
checked={col.visible}
|
|
345
|
+
onCheckedChange={() =>
|
|
346
|
+
toggleColumnVisibility(col.id)
|
|
347
|
+
}
|
|
348
|
+
/>
|
|
349
|
+
</span>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
})}
|
|
354
|
+
{/* Drop-at-end indicator: only ever rendered when the slot
|
|
355
|
+
points past the last row, so still exactly one line. */}
|
|
356
|
+
{dropSlot === orderedColumns.length && dragPath && (
|
|
357
|
+
<DropLine />
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<div className="h-px w-full bg-[#2C2D2E]" />
|
|
364
|
+
|
|
365
|
+
{/* Default Sort */}
|
|
366
|
+
<div className="space-y-3">
|
|
367
|
+
<SectionHeader title="Default Sort" />
|
|
368
|
+
{sortableColumns.length === 0 ? (
|
|
369
|
+
<p className="text-xs text-content-presentation-global-tertiary">
|
|
370
|
+
No sortable columns.
|
|
371
|
+
</p>
|
|
372
|
+
) : (
|
|
373
|
+
// Single-choice radio list (Figma 1612:30016): selecting a
|
|
374
|
+
// column sets config.sortBy; direction keeps config.sortOrder.
|
|
375
|
+
// Rows + dividers are flat siblings so the `peer` pattern
|
|
376
|
+
// can hide the dividers immediately before AND after a
|
|
377
|
+
// hovered row.
|
|
378
|
+
<RadioGroup
|
|
379
|
+
value={config.sortBy || undefined}
|
|
380
|
+
onValueChange={(v) => onConfigChange({ sortBy: v })}
|
|
381
|
+
className={cn(
|
|
382
|
+
"flex flex-col space-y-0 rounded-[12px] bg-[#1C1D1F] p-1",
|
|
383
|
+
// Wrapper containing the hovered row: hide its OWN
|
|
384
|
+
// divider (sits above the row).
|
|
385
|
+
"[&>div:has(>[role=radio]:hover)>.dv-divider]:opacity-0",
|
|
386
|
+
// Wrapper that directly follows the one with the hovered
|
|
387
|
+
// row: hide its divider (sits below the hovered row).
|
|
388
|
+
"[&>div:has(>[role=radio]:hover)+div>.dv-divider]:opacity-0",
|
|
389
|
+
)}
|
|
390
|
+
>
|
|
391
|
+
{sortableColumns.map((col, i) => {
|
|
392
|
+
const field = fieldByPath.get(col.id);
|
|
393
|
+
return (
|
|
394
|
+
<div key={col.id}>
|
|
395
|
+
{/* Edge-to-edge divider (Figma: no horizontal
|
|
396
|
+
inset). */}
|
|
397
|
+
{i > 0 && (
|
|
398
|
+
<div className="dv-divider h-px bg-[#2C2D2E]" />
|
|
399
|
+
)}
|
|
400
|
+
<RadioRow
|
|
401
|
+
value={col.id}
|
|
402
|
+
label={col.label || field?.label || col.id}
|
|
403
|
+
/>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
})}
|
|
407
|
+
</RadioGroup>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
) : (
|
|
412
|
+
<FilterPanel
|
|
413
|
+
variant="panel"
|
|
414
|
+
data={data}
|
|
415
|
+
fields={fields}
|
|
416
|
+
filters={filterState}
|
|
417
|
+
onFilterChange={(path: string, value: FilterValue) =>
|
|
418
|
+
onFilterChange({ ...filterState, [path]: value })
|
|
419
|
+
}
|
|
420
|
+
onClearAll={() => onFilterChange({})}
|
|
421
|
+
filterConfig={filterConfig}
|
|
422
|
+
/>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Search, Settings } from "lucide-react";
|
|
4
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
5
|
+
import type { ViewType } from "./types";
|
|
6
|
+
import { Button } from "../Button";
|
|
7
|
+
import { cn } from "../../utils/cn";
|
|
8
|
+
|
|
9
|
+
export type DataViewsHeaderView = {
|
|
10
|
+
id: ViewType;
|
|
11
|
+
label: string;
|
|
12
|
+
icon: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type DataViewsHeaderProps = {
|
|
16
|
+
title: string;
|
|
17
|
+
views: DataViewsHeaderView[];
|
|
18
|
+
currentView: ViewType;
|
|
19
|
+
onViewChange: (view: ViewType) => void;
|
|
20
|
+
showSettings: boolean;
|
|
21
|
+
settingsOpen: boolean;
|
|
22
|
+
onToggleSettings: () => void;
|
|
23
|
+
onAddNew?: () => void;
|
|
24
|
+
addNewLabel?: string;
|
|
25
|
+
searchValue?: string;
|
|
26
|
+
onSearchChange?: (value: string) => void;
|
|
27
|
+
searchPlaceholder?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function DataViewsHeader({
|
|
32
|
+
title,
|
|
33
|
+
views,
|
|
34
|
+
currentView,
|
|
35
|
+
onViewChange,
|
|
36
|
+
showSettings,
|
|
37
|
+
settingsOpen,
|
|
38
|
+
onToggleSettings,
|
|
39
|
+
onAddNew,
|
|
40
|
+
addNewLabel = "Add New",
|
|
41
|
+
searchValue,
|
|
42
|
+
onSearchChange,
|
|
43
|
+
searchPlaceholder = "Search...",
|
|
44
|
+
className,
|
|
45
|
+
}: DataViewsHeaderProps) {
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
// Header is always dark. data-theme="dark" makes the child Button
|
|
49
|
+
// components resolve dark-theme tokens (correct against the black bar)
|
|
50
|
+
// even when the host app runs in default/light theme.
|
|
51
|
+
data-theme="dark"
|
|
52
|
+
className={cn(
|
|
53
|
+
"flex h-[52px] w-full items-center gap-2 rounded-[12px] bg-black px-2",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{/* Title pill */}
|
|
58
|
+
<div className="flex h-9 shrink-0 items-center gap-2 rounded-[12px] border border-[#434446] bg-[#252729] px-[10px]">
|
|
59
|
+
<span className="text-[28px] font-[510] uppercase leading-[1.19] text-white">
|
|
60
|
+
{title}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Divider */}
|
|
65
|
+
<div className="h-5 w-px shrink-0 bg-[#434446]" />
|
|
66
|
+
|
|
67
|
+
{/* Segmented view switcher */}
|
|
68
|
+
<div className="flex flex-1 items-center gap-2">
|
|
69
|
+
<div className="flex items-center gap-[2px] rounded-[10px] bg-[#252729] p-[2px] shadow-[inset_0_0_4px_0_rgba(0,0,0,0.08)]">
|
|
70
|
+
{views.map((view, idx) => {
|
|
71
|
+
const active = view.id === currentView;
|
|
72
|
+
const prevActive = idx > 0 && views[idx - 1].id === currentView;
|
|
73
|
+
// Separator sits between two inactive tabs only; the active white
|
|
74
|
+
// pill never has a flanking divider (matches Figma).
|
|
75
|
+
const showDivider = idx > 0 && !active && !prevActive;
|
|
76
|
+
return (
|
|
77
|
+
<div key={view.id} className="flex items-center">
|
|
78
|
+
{showDivider && (
|
|
79
|
+
<div className="mx-[3px] h-3 w-px bg-[#434446]" />
|
|
80
|
+
)}
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
aria-pressed={active}
|
|
84
|
+
onClick={() => onViewChange(view.id)}
|
|
85
|
+
className={cn(
|
|
86
|
+
"flex h-6 items-center gap-[6px] rounded-[8px] px-3 text-[14px] font-[510] leading-none transition-all duration-200 ease-in-out",
|
|
87
|
+
active
|
|
88
|
+
? "bg-white text-black shadow-[0_0_10px_2px_rgba(0,0,0,0.25)]"
|
|
89
|
+
: "bg-transparent text-white hover:bg-white/5",
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
<span className="flex h-[14px] w-[14px] items-center justify-center [&_svg]:h-[14px] [&_svg]:w-[14px]">
|
|
93
|
+
{view.icon}
|
|
94
|
+
</span>
|
|
95
|
+
{view.label}
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Action bar */}
|
|
104
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
105
|
+
{onSearchChange && (
|
|
106
|
+
<HeaderSearch
|
|
107
|
+
value={searchValue ?? ""}
|
|
108
|
+
onChange={onSearchChange}
|
|
109
|
+
placeholder={searchPlaceholder}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
{onAddNew && (
|
|
113
|
+
<Button
|
|
114
|
+
variant="PrimeStyle"
|
|
115
|
+
size="M"
|
|
116
|
+
onClick={onAddNew}
|
|
117
|
+
className="rounded-[6px] bg-[#005ECC] px-[14px] text-[16px] font-[510] text-white hover:bg-[#005ECC]/90"
|
|
118
|
+
>
|
|
119
|
+
{addNewLabel}
|
|
120
|
+
</Button>
|
|
121
|
+
)}
|
|
122
|
+
{/* Hidden while the panel is open — the panel has its own close
|
|
123
|
+
control, so the header trigger would be redundant. */}
|
|
124
|
+
{showSettings && !settingsOpen && (
|
|
125
|
+
<Button
|
|
126
|
+
variant="BluContStyle"
|
|
127
|
+
size="M"
|
|
128
|
+
onClick={onToggleSettings}
|
|
129
|
+
aria-pressed={settingsOpen}
|
|
130
|
+
className="gap-[6px] rounded-[6px] px-[14px] text-[16px] font-[510]"
|
|
131
|
+
>
|
|
132
|
+
<Settings className="h-[18px] w-[18px]" />
|
|
133
|
+
Filter & Config.
|
|
134
|
+
</Button>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function HeaderSearch({
|
|
142
|
+
value,
|
|
143
|
+
onChange,
|
|
144
|
+
placeholder,
|
|
145
|
+
}: {
|
|
146
|
+
value: string;
|
|
147
|
+
onChange: (value: string) => void;
|
|
148
|
+
placeholder: string;
|
|
149
|
+
}) {
|
|
150
|
+
const [open, setOpen] = useState(false);
|
|
151
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
152
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (open) inputRef.current?.focus();
|
|
156
|
+
}, [open]);
|
|
157
|
+
|
|
158
|
+
// Auto-collapse on outside click only when the input is empty — keeps the
|
|
159
|
+
// expanded state if the user has typed a query but clicks away.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (!open) return;
|
|
162
|
+
function onPointerDown(e: MouseEvent) {
|
|
163
|
+
if (!wrapRef.current) return;
|
|
164
|
+
if (wrapRef.current.contains(e.target as Node)) return;
|
|
165
|
+
if (!value) setOpen(false);
|
|
166
|
+
}
|
|
167
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
168
|
+
return () => document.removeEventListener("mousedown", onPointerDown);
|
|
169
|
+
}, [open, value]);
|
|
170
|
+
|
|
171
|
+
function clearAndCollapse() {
|
|
172
|
+
onChange("");
|
|
173
|
+
setOpen(false);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!open) {
|
|
177
|
+
return (
|
|
178
|
+
<Button
|
|
179
|
+
variant="BluContStyle"
|
|
180
|
+
size="M"
|
|
181
|
+
buttonType="icon"
|
|
182
|
+
aria-label="Open search"
|
|
183
|
+
onClick={() => setOpen(true)}
|
|
184
|
+
className="shrink-0 rounded-[6px] border border-border-presentation-global-primary"
|
|
185
|
+
>
|
|
186
|
+
<Search className="h-[18px] w-[18px]" />
|
|
187
|
+
</Button>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div
|
|
193
|
+
ref={wrapRef}
|
|
194
|
+
className="relative flex h-[28px] w-[260px] shrink-0 items-center justify-center rounded-[6px] border border-border-presentation-state-focus bg-background-presentation-form-field-primary px-1 shadow-[0_1px_6px_0_rgba(0,0,0,0.30)] transition-all duration-150 ease-in-out"
|
|
195
|
+
>
|
|
196
|
+
<input
|
|
197
|
+
ref={inputRef}
|
|
198
|
+
type="text"
|
|
199
|
+
value={value}
|
|
200
|
+
placeholder={placeholder}
|
|
201
|
+
onChange={(e) => onChange(e.target.value)}
|
|
202
|
+
onKeyDown={(e) => {
|
|
203
|
+
if (e.key === "Escape") clearAndCollapse();
|
|
204
|
+
}}
|
|
205
|
+
className="flex-1 bg-transparent text-[14px] leading-none text-white caret-[#1E7AFE] placeholder:text-content-presentation-global-tertiary focus:outline-none"
|
|
206
|
+
/>
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
aria-label="Clear search"
|
|
210
|
+
onClick={clearAndCollapse}
|
|
211
|
+
className="flex shrink-0 items-center justify-center self-stretch px-1"
|
|
212
|
+
>
|
|
213
|
+
<svg
|
|
214
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
215
|
+
width="16"
|
|
216
|
+
height="16"
|
|
217
|
+
viewBox="0 0 16 16"
|
|
218
|
+
fill="none"
|
|
219
|
+
>
|
|
220
|
+
<path
|
|
221
|
+
d="M7.99992 14.6666C4.31802 14.6666 1.33325 11.6818 1.33325 7.99992C1.33325 4.31802 4.31802 1.33325 7.99992 1.33325C11.6818 1.33325 14.6666 4.31802 14.6666 7.99992C14.6666 11.6818 11.6818 14.6666 7.99992 14.6666ZM7.99992 7.05712L6.1143 5.17149L5.17149 6.1143L7.05712 7.99992L5.17149 9.88552L6.1143 10.8283L7.99992 8.94272L9.88552 10.8283L10.8283 9.88552L8.94272 7.99992L10.8283 6.1143L9.88552 5.17149L7.99992 7.05712Z"
|
|
222
|
+
fill="white"
|
|
223
|
+
/>
|
|
224
|
+
</svg>
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|