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,353 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type React from "react";
|
|
4
|
+
import { Fragment, useMemo, useState } from "react";
|
|
5
|
+
import { MoreHorizontal } from "lucide-react";
|
|
6
|
+
import type {
|
|
7
|
+
DynamicRecord,
|
|
8
|
+
ViewConfig,
|
|
9
|
+
DynamicColumnConfig,
|
|
10
|
+
FieldConfig,
|
|
11
|
+
KanbanColumnColor,
|
|
12
|
+
} from "./types";
|
|
13
|
+
import { Button } from "../Button";
|
|
14
|
+
import { DataViewCard, type DataViewCardRow } from "../../layouts/DataViewCard";
|
|
15
|
+
import { getByPath, setByPath } from "../../utils/dataViews/pathUtils";
|
|
16
|
+
import { renderField } from "./fieldRenderers";
|
|
17
|
+
import { visibleFields } from "../../utils/dataViews/fieldUtils";
|
|
18
|
+
import { useIsMobile } from "../../hooks/useIsMobile";
|
|
19
|
+
import { cn } from "../../utils/cn";
|
|
20
|
+
|
|
21
|
+
export type KanbanViewProps = {
|
|
22
|
+
data: DynamicRecord[];
|
|
23
|
+
columns?: DynamicColumnConfig[];
|
|
24
|
+
fields: FieldConfig[];
|
|
25
|
+
config: ViewConfig;
|
|
26
|
+
onDataUpdate?: (data: DynamicRecord[]) => void;
|
|
27
|
+
groupByField?: string;
|
|
28
|
+
// Path of the field to render as the card title. Defaults to the first
|
|
29
|
+
// visible non-group-by field. Use this to opt out of the "first field wins"
|
|
30
|
+
// heuristic when consumers want a specific field (e.g. "name", "id").
|
|
31
|
+
titleField?: string;
|
|
32
|
+
// Click handler for the column header's overflow button. When omitted the
|
|
33
|
+
// button is hidden so app-less columns stay clean.
|
|
34
|
+
onColumnAction?: (columnId: string) => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const COLUMN_PALETTE: readonly KanbanColumnColor[] = [
|
|
38
|
+
"gray",
|
|
39
|
+
"purple",
|
|
40
|
+
"orange",
|
|
41
|
+
"blue",
|
|
42
|
+
"green",
|
|
43
|
+
"red",
|
|
44
|
+
] as const;
|
|
45
|
+
type ColumnColor = KanbanColumnColor;
|
|
46
|
+
|
|
47
|
+
// Figma kanban header pills use deeply saturated dark fills (#131415, #330C69,
|
|
48
|
+
// #532200, #002F66). We match each to the closest existing raw-color token in
|
|
49
|
+
// `glare-torch-mode`. Purple has no presentation-layer match close enough, so
|
|
50
|
+
// we use the exact Figma hex inline.
|
|
51
|
+
const COLUMN_BG: Record<ColumnColor, string> = {
|
|
52
|
+
gray: "bg-black-900",
|
|
53
|
+
purple: "bg-[#330C69]",
|
|
54
|
+
orange: "bg-orange-900",
|
|
55
|
+
blue: "bg-blue-sparkle-900",
|
|
56
|
+
green: "bg-green-cyan-900",
|
|
57
|
+
red: "bg-red-orange-900",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const colorIndexFor = (key: string) => {
|
|
61
|
+
let h = 0;
|
|
62
|
+
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
|
|
63
|
+
return Math.abs(h) % COLUMN_PALETTE.length;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type KanbanColumn = {
|
|
67
|
+
id: string;
|
|
68
|
+
title: string;
|
|
69
|
+
color: ColumnColor;
|
|
70
|
+
items: DynamicRecord[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function getId(
|
|
74
|
+
item: DynamicRecord,
|
|
75
|
+
fallbackPath: string | undefined,
|
|
76
|
+
idx: number,
|
|
77
|
+
): any {
|
|
78
|
+
if (item?.id != null) return item.id;
|
|
79
|
+
if (fallbackPath) {
|
|
80
|
+
const v = getByPath(item, fallbackPath);
|
|
81
|
+
if (v != null) return v;
|
|
82
|
+
}
|
|
83
|
+
return idx;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function KanbanView({
|
|
87
|
+
data,
|
|
88
|
+
fields,
|
|
89
|
+
onDataUpdate,
|
|
90
|
+
groupByField = "status",
|
|
91
|
+
titleField,
|
|
92
|
+
onColumnAction,
|
|
93
|
+
}: KanbanViewProps) {
|
|
94
|
+
const isMobile = useIsMobile();
|
|
95
|
+
const [draggedItem, setDraggedItem] = useState<{
|
|
96
|
+
item: DynamicRecord;
|
|
97
|
+
columnId: string;
|
|
98
|
+
} | null>(null);
|
|
99
|
+
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
|
|
100
|
+
|
|
101
|
+
const displayFields = useMemo(
|
|
102
|
+
() => visibleFields(fields).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
|
103
|
+
[fields],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const groupField = useMemo(
|
|
107
|
+
() => fields.find((f) => f.path === groupByField),
|
|
108
|
+
[fields, groupByField],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const kanbanColumns = useMemo<KanbanColumn[]>(() => {
|
|
112
|
+
const groups: Record<string, KanbanColumn> = {};
|
|
113
|
+
const overrides = groupField?.kanbanVariants;
|
|
114
|
+
|
|
115
|
+
// Resolve a column's visible title + pill color. Consumer-supplied
|
|
116
|
+
// `kanbanVariants[key]` wins; otherwise fall back to the raw key and the
|
|
117
|
+
// palette rotation.
|
|
118
|
+
const resolve = (key: string, paletteIdx: number) => ({
|
|
119
|
+
title: overrides?.[key]?.label ?? key,
|
|
120
|
+
color:
|
|
121
|
+
overrides?.[key]?.color ??
|
|
122
|
+
COLUMN_PALETTE[paletteIdx % COLUMN_PALETTE.length],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (groupField?.variants) {
|
|
126
|
+
Object.keys(groupField.variants).forEach((value, index) => {
|
|
127
|
+
groups[value] = {
|
|
128
|
+
id: value,
|
|
129
|
+
...resolve(value, index),
|
|
130
|
+
items: [],
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const item of data) {
|
|
136
|
+
const value = String(getByPath(item, groupByField) ?? "Uncategorized");
|
|
137
|
+
if (!groups[value]) {
|
|
138
|
+
groups[value] = {
|
|
139
|
+
id: value,
|
|
140
|
+
...resolve(value, colorIndexFor(value)),
|
|
141
|
+
items: [],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
groups[value].items.push(item);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return Object.values(groups);
|
|
148
|
+
}, [data, groupByField, groupField]);
|
|
149
|
+
|
|
150
|
+
const handleDragStart = (item: DynamicRecord, columnId: string) => {
|
|
151
|
+
setDraggedItem({ item, columnId });
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleDragOver = (e: React.DragEvent, columnId: string) => {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
e.dataTransfer.dropEffect = "move";
|
|
157
|
+
if (dragOverColumnId !== columnId) setDragOverColumnId(columnId);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const handleDragLeave = (e: React.DragEvent, columnId: string) => {
|
|
161
|
+
// Only clear when the pointer actually exits this column — moving over a
|
|
162
|
+
// child element fires dragleave on the parent before dragenter on the child.
|
|
163
|
+
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
|
164
|
+
if (dragOverColumnId === columnId) setDragOverColumnId(null);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleDragEnd = () => {
|
|
168
|
+
setDraggedItem(null);
|
|
169
|
+
setDragOverColumnId(null);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const idPath = displayFields[0]?.path;
|
|
173
|
+
|
|
174
|
+
const handleDrop = (targetColumnId: string) => {
|
|
175
|
+
if (!draggedItem) {
|
|
176
|
+
setDragOverColumnId(null);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const draggedId = getId(draggedItem.item, idPath, -1);
|
|
180
|
+
|
|
181
|
+
const updatedData = data.map((item, idx) => {
|
|
182
|
+
const itemId = getId(item, idPath, idx);
|
|
183
|
+
if (itemId === draggedId) {
|
|
184
|
+
return setByPath(item, groupByField, targetColumnId);
|
|
185
|
+
}
|
|
186
|
+
return item;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
onDataUpdate?.(updatedData);
|
|
190
|
+
setDraggedItem(null);
|
|
191
|
+
setDragOverColumnId(null);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Resolve the title field: consumer-supplied `titleField` wins, else fall
|
|
195
|
+
// back to the first visible non-group-by field.
|
|
196
|
+
const resolvedTitleField = useMemo(() => {
|
|
197
|
+
if (titleField) return displayFields.find((f) => f.path === titleField);
|
|
198
|
+
return displayFields.find((f) => f.path !== groupByField);
|
|
199
|
+
}, [displayFields, titleField, groupByField]);
|
|
200
|
+
|
|
201
|
+
const renderCard = (item: DynamicRecord, idx: number) => {
|
|
202
|
+
const itemId = getId(item, idPath, idx);
|
|
203
|
+
const isDraggingThis =
|
|
204
|
+
draggedItem != null && getId(draggedItem.item, idPath, -1) === itemId;
|
|
205
|
+
const titleFieldResolved = resolvedTitleField;
|
|
206
|
+
const titleValue = titleFieldResolved
|
|
207
|
+
? getByPath(item, titleFieldResolved.path)
|
|
208
|
+
: "";
|
|
209
|
+
const bodyFields = displayFields.filter(
|
|
210
|
+
(f) => f.path !== groupByField && f.path !== titleFieldResolved?.path,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Pair body fields two-per-row so the grid keeps its alternating rhythm
|
|
214
|
+
// even when one side is missing. If a pair has only one non-null value, the
|
|
215
|
+
// surviving cell spans both columns. Fully empty pairs are dropped so we
|
|
216
|
+
// don't render a phantom row with only hairlines.
|
|
217
|
+
const rows: DataViewCardRow[] = [];
|
|
218
|
+
for (let i = 0; i < bodyFields.length; i += 2) {
|
|
219
|
+
const left = bodyFields[i];
|
|
220
|
+
const right = bodyFields[i + 1];
|
|
221
|
+
const leftValue = left ? getByPath(item, left.path) : null;
|
|
222
|
+
const rightValue = right ? getByPath(item, right.path) : null;
|
|
223
|
+
const cells: DataViewCardRow = [];
|
|
224
|
+
if (left && leftValue != null) {
|
|
225
|
+
cells.push({
|
|
226
|
+
key: left.path,
|
|
227
|
+
label: left.label,
|
|
228
|
+
value: renderField(leftValue, left, item),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (right && rightValue != null) {
|
|
232
|
+
cells.push({
|
|
233
|
+
key: right.path,
|
|
234
|
+
label: right.label,
|
|
235
|
+
value: renderField(rightValue, right, item),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (cells.length > 0) rows.push(cells);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<DataViewCard
|
|
243
|
+
key={itemId}
|
|
244
|
+
draggable={!isMobile}
|
|
245
|
+
onDragStart={
|
|
246
|
+
!isMobile
|
|
247
|
+
? () =>
|
|
248
|
+
handleDragStart(
|
|
249
|
+
item,
|
|
250
|
+
String(getByPath(item, groupByField) ?? "Uncategorized"),
|
|
251
|
+
)
|
|
252
|
+
: undefined
|
|
253
|
+
}
|
|
254
|
+
onDragEnd={!isMobile ? handleDragEnd : undefined}
|
|
255
|
+
className={cn(
|
|
256
|
+
isMobile
|
|
257
|
+
? "cursor-pointer"
|
|
258
|
+
: "cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow",
|
|
259
|
+
!isMobile && isDraggingThis && "opacity-40",
|
|
260
|
+
)}
|
|
261
|
+
title={
|
|
262
|
+
titleFieldResolved &&
|
|
263
|
+
renderField(titleValue, titleFieldResolved, item)
|
|
264
|
+
}
|
|
265
|
+
rows={rows}
|
|
266
|
+
/>
|
|
267
|
+
);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (isMobile) {
|
|
271
|
+
return (
|
|
272
|
+
<div className="h-full overflow-y-auto p-4 bg-background-presentation-body-primary">
|
|
273
|
+
<div className="flex flex-col gap-4">
|
|
274
|
+
{kanbanColumns.map((column) => (
|
|
275
|
+
<div key={column.id} className="flex flex-col gap-3">
|
|
276
|
+
<ColumnHeader column={column} onAction={onColumnAction} />
|
|
277
|
+
<div className="flex flex-col gap-3">
|
|
278
|
+
{column.items.map((item, idx) => renderCard(item, idx))}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div className="h-full overflow-x-auto p-2 bg-background-presentation-body-primary">
|
|
289
|
+
<div className="flex h-full gap-4" style={{ minWidth: "max-content" }}>
|
|
290
|
+
{kanbanColumns.map((column, i) => {
|
|
291
|
+
const isDropTarget =
|
|
292
|
+
draggedItem != null && dragOverColumnId === column.id;
|
|
293
|
+
return (
|
|
294
|
+
<Fragment key={column.id}>
|
|
295
|
+
<div
|
|
296
|
+
className={cn(
|
|
297
|
+
"flex w-[279px] flex-col gap-2 rounded-[12px] p-1 transition-colors duration-150 ease-in-out border-2 border-transparent",
|
|
298
|
+
isDropTarget &&
|
|
299
|
+
"bg-background-presentation-cardbutton-blue-hover border-dashed border-border-presentation-state-focus",
|
|
300
|
+
)}
|
|
301
|
+
onDragOver={(e) => handleDragOver(e, column.id)}
|
|
302
|
+
onDragLeave={(e) => handleDragLeave(e, column.id)}
|
|
303
|
+
onDrop={() => handleDrop(column.id)}
|
|
304
|
+
>
|
|
305
|
+
<ColumnHeader column={column} onAction={onColumnAction} />
|
|
306
|
+
<div className="flex flex-col gap-2 overflow-y-auto py-1">
|
|
307
|
+
{column.items.map((item, idx) => renderCard(item, idx))}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
{i < kanbanColumns.length - 1 && (
|
|
311
|
+
<div
|
|
312
|
+
aria-hidden
|
|
313
|
+
className="self-stretch mt-[42px] border-dashed border-l-[2px] border-border-presentation-global-primary"
|
|
314
|
+
/>
|
|
315
|
+
)}
|
|
316
|
+
</Fragment>
|
|
317
|
+
);
|
|
318
|
+
})}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function ColumnHeader({
|
|
325
|
+
column,
|
|
326
|
+
onAction,
|
|
327
|
+
}: {
|
|
328
|
+
column: KanbanColumn;
|
|
329
|
+
onAction?: (columnId: string) => void;
|
|
330
|
+
}) {
|
|
331
|
+
return (
|
|
332
|
+
<div
|
|
333
|
+
className={cn(
|
|
334
|
+
"flex items-center justify-between rounded-[8px] px-[6px] py-[4px]",
|
|
335
|
+
COLUMN_BG[column.color],
|
|
336
|
+
)}
|
|
337
|
+
>
|
|
338
|
+
<h3 className="typography-headers-small-medium text-content-presentation-global-primary-light">
|
|
339
|
+
{column.title}
|
|
340
|
+
</h3>
|
|
341
|
+
{onAction && (
|
|
342
|
+
<Button
|
|
343
|
+
variant="BorderStyle"
|
|
344
|
+
buttonType="icon"
|
|
345
|
+
className="h-5 w-5 border-0 bg-transparent text-content-presentation-global-primary-light hover:bg-white/10"
|
|
346
|
+
onClick={() => onAction(column.id)}
|
|
347
|
+
>
|
|
348
|
+
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
349
|
+
</Button>
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Switch } from "../Switch";
|
|
4
|
+
import { DataViewRadio } from "./DataViewRadio";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* DataViews config-panel form controls.
|
|
8
|
+
*
|
|
9
|
+
* The config panel is wrapped in `data-theme="dark"`, so theme-aware
|
|
10
|
+
* components (DataViewRadio, Switch) render in dark mode automatically. The
|
|
11
|
+
* green-checked Switch hex is the only thing this panel still hardcodes
|
|
12
|
+
* because it sits outside the theme system.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Saved View / Default Sort radio row.
|
|
17
|
+
*
|
|
18
|
+
* Thin wrapper around the reusable {@link DataViewRadio} so the config panel
|
|
19
|
+
* keeps a stable name. The whole row IS the Radix Item, so the entire area
|
|
20
|
+
* (circle + label + padding) is one click target.
|
|
21
|
+
*/
|
|
22
|
+
export function RadioRow({ value, label }: { value: string; label: string }) {
|
|
23
|
+
return <DataViewRadio value={value} label={label} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Shared <Switch> with the bright-green checked track (#0AC713) from the Figma
|
|
27
|
+
// Switcher-1.0 "On" state, applied regardless of the panel's dark theme scope.
|
|
28
|
+
const SWITCH_GREEN =
|
|
29
|
+
"data-[state=checked]:bg-[#0AC713] data-[state=checked]:border-[#0AC713]";
|
|
30
|
+
|
|
31
|
+
type DataViewsSwitchProps = {
|
|
32
|
+
checked: boolean;
|
|
33
|
+
onCheckedChange: () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Column show/hide toggle: the library <Switch> pre-styled to the panel's
|
|
37
|
+
* Figma green-checked spec. */
|
|
38
|
+
export function DataViewsSwitch({
|
|
39
|
+
checked,
|
|
40
|
+
onCheckedChange,
|
|
41
|
+
}: DataViewsSwitchProps) {
|
|
42
|
+
return (
|
|
43
|
+
<Switch
|
|
44
|
+
checked={checked}
|
|
45
|
+
onCheckedChange={onCheckedChange}
|
|
46
|
+
className={SWITCH_GREEN}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react"
|
|
4
|
+
import { X, GripVertical, ArrowUp, ArrowDown, Minus } from "lucide-react"
|
|
5
|
+
import type {
|
|
6
|
+
ViewConfig,
|
|
7
|
+
ViewType,
|
|
8
|
+
FieldConfig,
|
|
9
|
+
} from "./types"
|
|
10
|
+
import { Button } from "../Button"
|
|
11
|
+
import { Switch } from "../Switch"
|
|
12
|
+
import { Divider } from "../Divider"
|
|
13
|
+
import { Label } from "../Label"
|
|
14
|
+
import { RadioGroup, Radio } from "../Radio"
|
|
15
|
+
|
|
16
|
+
type SettingsPanelProps = {
|
|
17
|
+
config: ViewConfig
|
|
18
|
+
onConfigChange: (config: Partial<ViewConfig>) => void
|
|
19
|
+
onClose: () => void
|
|
20
|
+
currentView: ViewType
|
|
21
|
+
fields: FieldConfig[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function SettingsPanel({
|
|
25
|
+
config,
|
|
26
|
+
onConfigChange,
|
|
27
|
+
onClose,
|
|
28
|
+
currentView,
|
|
29
|
+
fields,
|
|
30
|
+
}: SettingsPanelProps) {
|
|
31
|
+
const visibleFields = useMemo(
|
|
32
|
+
() => fields.filter((f) => f.type !== "hidden"),
|
|
33
|
+
[fields],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const groupableFields = useMemo(
|
|
37
|
+
() => fields.filter((f) => f.type === "enum-badge"),
|
|
38
|
+
[fields],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const visiblePaths = useMemo(
|
|
42
|
+
() => new Set(visibleFields.map((f) => f.path)),
|
|
43
|
+
[visibleFields],
|
|
44
|
+
)
|
|
45
|
+
const fieldByPath = useMemo(
|
|
46
|
+
() => new Map(visibleFields.map((f) => [f.path, f])),
|
|
47
|
+
[visibleFields],
|
|
48
|
+
)
|
|
49
|
+
const orderedColumns = useMemo(
|
|
50
|
+
() =>
|
|
51
|
+
[...config.tableColumns]
|
|
52
|
+
.filter((c) => visiblePaths.has(c.id))
|
|
53
|
+
.sort((a, b) => a.order - b.order),
|
|
54
|
+
[config.tableColumns, visiblePaths],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const toggleColumnVisibility = (path: string) => {
|
|
58
|
+
const next = config.tableColumns.map((c) =>
|
|
59
|
+
c.id === path ? { ...c, visible: !c.visible } : c,
|
|
60
|
+
)
|
|
61
|
+
onConfigChange({ tableColumns: next })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const [dragPath, setDragPath] = useState<string | null>(null)
|
|
65
|
+
const [dragOverPath, setDragOverPath] = useState<string | null>(null)
|
|
66
|
+
|
|
67
|
+
const reorderColumn = (sourcePath: string, targetPath: string) => {
|
|
68
|
+
if (sourcePath === targetPath) return
|
|
69
|
+
const ids = orderedColumns.map((c) => c.id)
|
|
70
|
+
const from = ids.indexOf(sourcePath)
|
|
71
|
+
const to = ids.indexOf(targetPath)
|
|
72
|
+
if (from === -1 || to === -1) return
|
|
73
|
+
const reordered = [...ids]
|
|
74
|
+
const [moved] = reordered.splice(from, 1)
|
|
75
|
+
reordered.splice(to, 0, moved)
|
|
76
|
+
const orderByPath = new Map(reordered.map((id, i) => [id, i]))
|
|
77
|
+
const next = config.tableColumns.map((c) => {
|
|
78
|
+
const newOrder = orderByPath.get(c.id)
|
|
79
|
+
return newOrder == null ? c : { ...c, order: newOrder }
|
|
80
|
+
})
|
|
81
|
+
onConfigChange({ tableColumns: next })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="w-80 border-l border-border-presentation-global-primary bg-background-presentation-body-overlay-primary overflow-y-auto">
|
|
86
|
+
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-border-presentation-global-primary bg-background-presentation-body-overlay-primary p-4">
|
|
87
|
+
<h2 className="font-semibold text-content-presentation-global-primary">Settings</h2>
|
|
88
|
+
<Button variant="BorderStyle" buttonType="icon" onClick={onClose} className="h-8 w-8">
|
|
89
|
+
<X className="h-4 w-4" />
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div className="space-y-6 p-4">
|
|
94
|
+
<div className="space-y-3">
|
|
95
|
+
<h3 className="text-sm font-medium text-content-presentation-global-primary">General</h3>
|
|
96
|
+
<div className="space-y-3">
|
|
97
|
+
<div className="flex items-center justify-between">
|
|
98
|
+
<Label htmlFor="show-filters">Show Filters</Label>
|
|
99
|
+
<Switch
|
|
100
|
+
id="show-filters"
|
|
101
|
+
checked={config.showFilters}
|
|
102
|
+
onCheckedChange={(checked) => onConfigChange({ showFilters: checked })}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<Divider />
|
|
109
|
+
|
|
110
|
+
{currentView === "table" && (
|
|
111
|
+
<>
|
|
112
|
+
<div className="space-y-3">
|
|
113
|
+
<h3 className="text-sm font-medium text-content-presentation-global-primary">Table Columns</h3>
|
|
114
|
+
<p className="text-xs text-content-presentation-global-tertiary">
|
|
115
|
+
Show or hide columns in table view
|
|
116
|
+
</p>
|
|
117
|
+
{orderedColumns.length === 0 ? (
|
|
118
|
+
<p className="text-xs text-content-presentation-global-tertiary">No fields detected.</p>
|
|
119
|
+
) : (
|
|
120
|
+
<div className="space-y-2">
|
|
121
|
+
{orderedColumns.map((col) => {
|
|
122
|
+
const field = fieldByPath.get(col.id)
|
|
123
|
+
const isDragging = dragPath === col.id
|
|
124
|
+
const isDropTarget = dragOverPath === col.id && dragPath !== col.id
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
key={col.id}
|
|
128
|
+
draggable
|
|
129
|
+
onDragStart={(e) => {
|
|
130
|
+
setDragPath(col.id)
|
|
131
|
+
e.dataTransfer.effectAllowed = "move"
|
|
132
|
+
e.dataTransfer.setData("text/plain", col.id)
|
|
133
|
+
}}
|
|
134
|
+
onDragOver={(e) => {
|
|
135
|
+
e.preventDefault()
|
|
136
|
+
e.dataTransfer.dropEffect = "move"
|
|
137
|
+
if (dragOverPath !== col.id) setDragOverPath(col.id)
|
|
138
|
+
}}
|
|
139
|
+
onDragLeave={() => {
|
|
140
|
+
if (dragOverPath === col.id) setDragOverPath(null)
|
|
141
|
+
}}
|
|
142
|
+
onDrop={(e) => {
|
|
143
|
+
e.preventDefault()
|
|
144
|
+
if (dragPath) reorderColumn(dragPath, col.id)
|
|
145
|
+
setDragPath(null)
|
|
146
|
+
setDragOverPath(null)
|
|
147
|
+
}}
|
|
148
|
+
onDragEnd={() => {
|
|
149
|
+
setDragPath(null)
|
|
150
|
+
setDragOverPath(null)
|
|
151
|
+
}}
|
|
152
|
+
className={
|
|
153
|
+
"flex items-center gap-2 rounded-lg border p-2 cursor-grab active:cursor-grabbing transition-colors " +
|
|
154
|
+
(isDragging
|
|
155
|
+
? "opacity-50 border-border-presentation-global-primary "
|
|
156
|
+
: isDropTarget
|
|
157
|
+
? "border-content-presentation-action-light-primary bg-background-presentation-form-field-primary "
|
|
158
|
+
: "border-border-presentation-global-primary ")
|
|
159
|
+
}
|
|
160
|
+
>
|
|
161
|
+
<GripVertical className="h-4 w-4 text-content-presentation-global-tertiary" />
|
|
162
|
+
<span className="flex-1 text-sm text-content-presentation-global-primary">
|
|
163
|
+
{col.label || field?.label || col.id}
|
|
164
|
+
</span>
|
|
165
|
+
<Switch
|
|
166
|
+
checked={col.visible}
|
|
167
|
+
onCheckedChange={() => toggleColumnVisibility(col.id)}
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
})}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
<Divider />
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{currentView === "kanban" && (
|
|
180
|
+
<>
|
|
181
|
+
<div className="space-y-3">
|
|
182
|
+
<h3 className="text-sm font-medium text-content-presentation-global-primary">Kanban Grouping</h3>
|
|
183
|
+
<p className="text-xs text-content-presentation-global-tertiary">Group cards by field</p>
|
|
184
|
+
{groupableFields.length === 0 ? (
|
|
185
|
+
<p className="text-xs text-content-presentation-global-tertiary">
|
|
186
|
+
No groupable fields detected. Declare a field with type "enum-badge" to enable grouping.
|
|
187
|
+
</p>
|
|
188
|
+
) : (
|
|
189
|
+
<RadioGroup
|
|
190
|
+
value={config.kanbanGroupBy}
|
|
191
|
+
onValueChange={(value) => onConfigChange({ kanbanGroupBy: value })}
|
|
192
|
+
>
|
|
193
|
+
{groupableFields.map((field) => (
|
|
194
|
+
<div key={field.path} className="flex items-center space-x-2">
|
|
195
|
+
<Radio value={field.path} id={`group-${field.path}`} />
|
|
196
|
+
<Label htmlFor={`group-${field.path}`}>{field.label ?? field.path}</Label>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
</RadioGroup>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
<Divider />
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{currentView === "inbox" && (
|
|
207
|
+
<>
|
|
208
|
+
<div className="space-y-3">
|
|
209
|
+
<h3 className="text-sm font-medium text-content-presentation-global-primary">Inbox Layout</h3>
|
|
210
|
+
<div className="flex items-center justify-between">
|
|
211
|
+
<Label htmlFor="preview-pane" className="text-sm">
|
|
212
|
+
Show Preview Pane
|
|
213
|
+
</Label>
|
|
214
|
+
<Switch
|
|
215
|
+
id="preview-pane"
|
|
216
|
+
checked={config.showPreviewPane}
|
|
217
|
+
onCheckedChange={(checked) => onConfigChange({ showPreviewPane: checked })}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<Divider />
|
|
222
|
+
</>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{orderedColumns.length > 0 && (
|
|
226
|
+
<div className="space-y-3">
|
|
227
|
+
<h3 className="text-sm font-medium text-content-presentation-global-primary">Sort</h3>
|
|
228
|
+
<p className="text-xs text-content-presentation-global-tertiary">
|
|
229
|
+
Pick a column and direction. Only one column sorts at a time.
|
|
230
|
+
</p>
|
|
231
|
+
<div className="space-y-2">
|
|
232
|
+
{orderedColumns.map((col) => {
|
|
233
|
+
const field = fieldByPath.get(col.id)
|
|
234
|
+
const isActive = config.sortBy === col.id
|
|
235
|
+
const dir: "asc" | "desc" | "none" = isActive ? config.sortOrder : "none"
|
|
236
|
+
const setDir = (next: "asc" | "desc" | "none") => {
|
|
237
|
+
if (next === "none") {
|
|
238
|
+
onConfigChange({ sortBy: "" })
|
|
239
|
+
} else {
|
|
240
|
+
onConfigChange({ sortBy: col.id, sortOrder: next })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const btn = (
|
|
244
|
+
mode: "none" | "asc" | "desc",
|
|
245
|
+
Icon: typeof Minus,
|
|
246
|
+
label: string,
|
|
247
|
+
) => (
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
aria-label={`${label} ${col.label || col.id}`}
|
|
251
|
+
aria-pressed={dir === mode}
|
|
252
|
+
onClick={() => setDir(mode)}
|
|
253
|
+
className={
|
|
254
|
+
"flex h-7 w-7 items-center justify-center rounded-md border transition-colors " +
|
|
255
|
+
(dir === mode
|
|
256
|
+
? "border-content-presentation-action-light-primary bg-background-presentation-form-field-primary text-content-presentation-global-primary"
|
|
257
|
+
: "border-border-presentation-global-primary text-content-presentation-global-tertiary hover:text-content-presentation-global-primary")
|
|
258
|
+
}
|
|
259
|
+
>
|
|
260
|
+
<Icon className="h-3.5 w-3.5" />
|
|
261
|
+
</button>
|
|
262
|
+
)
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
key={col.id}
|
|
266
|
+
className="flex items-center gap-2 rounded-lg border border-border-presentation-global-primary p-2"
|
|
267
|
+
>
|
|
268
|
+
<span className="flex-1 text-sm text-content-presentation-global-primary">
|
|
269
|
+
{col.label || field?.label || col.id}
|
|
270
|
+
</span>
|
|
271
|
+
<div className="flex items-center gap-1">
|
|
272
|
+
{btn("none", Minus, "No sort")}
|
|
273
|
+
{btn("asc", ArrowUp, "Sort ascending")}
|
|
274
|
+
{btn("desc", ArrowDown, "Sort descending")}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
})}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
)
|
|
285
|
+
}
|