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.
Files changed (73) hide show
  1. package/apps/lib/components/Avatar.tsx +1 -1
  2. package/apps/lib/components/BadgeField.tsx +2 -2
  3. package/apps/lib/components/Card.tsx +68 -54
  4. package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
  5. package/apps/lib/components/DataViews/DataViewRadio.tsx +47 -0
  6. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +427 -0
  7. package/apps/lib/components/DataViews/DataViewsHeader.tsx +228 -0
  8. package/apps/lib/components/DataViews/DataViewsLayout.tsx +330 -0
  9. package/apps/lib/components/DataViews/FilterPanel.tsx +469 -0
  10. package/apps/lib/components/DataViews/HeaderSearch.tsx +97 -0
  11. package/apps/lib/components/DataViews/InboxView.tsx +495 -0
  12. package/apps/lib/components/DataViews/InboxViewCard.tsx +136 -0
  13. package/apps/lib/components/DataViews/KanbanView.tsx +353 -0
  14. package/apps/lib/components/DataViews/PanelControls.tsx +49 -0
  15. package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
  16. package/apps/lib/components/DataViews/TableView.tsx +232 -0
  17. package/apps/lib/components/DataViews/TreeView.tsx +392 -0
  18. package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
  19. package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
  20. package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
  21. package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
  22. package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
  23. package/apps/lib/components/DataViews/index.ts +36 -0
  24. package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
  25. package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
  26. package/apps/lib/components/DataViews/types.ts +206 -0
  27. package/apps/lib/components/Radio.tsx +18 -21
  28. package/apps/lib/components/Switch.tsx +3 -1
  29. package/apps/lib/components/Table.tsx +1 -1
  30. package/apps/lib/components/TreeFolder/TreeFolder.tsx +410 -0
  31. package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
  32. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +363 -0
  33. package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
  34. package/apps/lib/components/TreeFolder/icons.tsx +63 -0
  35. package/apps/lib/components/TreeFolder/index.ts +17 -0
  36. package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
  37. package/apps/lib/components/TreeFolder/types.ts +77 -0
  38. package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
  39. package/apps/lib/hooks/useDataViewsState.ts +169 -0
  40. package/apps/lib/hooks/useIsMobile.ts +21 -0
  41. package/apps/lib/layouts/DataViewCard.tsx +76 -0
  42. package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
  43. package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
  44. package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
  45. package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
  46. package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
  47. package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
  48. package/dist/bin/index.js +3 -3
  49. package/dist/bin/index.js.map +1 -1
  50. package/dist/src/commands/add.d.ts.map +1 -1
  51. package/dist/src/commands/add.js +29 -6
  52. package/dist/src/commands/add.js.map +1 -1
  53. package/dist/src/commands/utils.d.ts.map +1 -1
  54. package/dist/src/commands/utils.js +22 -2
  55. package/dist/src/commands/utils.js.map +1 -1
  56. package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
  57. package/dist/src/shared/copyComponentsRecursively.js +17 -2
  58. package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
  59. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
  60. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
  61. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
  62. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
  63. package/docs/components/data-views-config-panel.md +204 -0
  64. package/docs/components/data-views-layout.md +270 -0
  65. package/docs/components/form-stepper.md +244 -0
  66. package/docs/components/stepper.md +215 -0
  67. package/docs/components/timeline.md +248 -0
  68. package/package.json +6 -6
  69. package/apps/lib/components/Charts-dev.tsx +0 -365
  70. package/apps/lib/components/Command-dev.tsx +0 -151
  71. package/apps/lib/components/IosDatePicker-dev.tsx +0 -341
  72. /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
  73. /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
+ }