torch-glare 2.1.2 → 2.1.4

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 (30) hide show
  1. package/apps/lib/components/Avatar.tsx +1 -1
  2. package/apps/lib/components/Card.tsx +68 -54
  3. package/apps/lib/components/DataViews/DataViewRadio.tsx +47 -0
  4. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +56 -45
  5. package/apps/lib/components/DataViews/DataViewsHeader.tsx +130 -28
  6. package/apps/lib/components/DataViews/DataViewsLayout.tsx +32 -2
  7. package/apps/lib/components/DataViews/FilterPanel.tsx +148 -3
  8. package/apps/lib/components/DataViews/HeaderSearch.tsx +97 -0
  9. package/apps/lib/components/DataViews/InboxView.tsx +263 -282
  10. package/apps/lib/components/DataViews/InboxViewCard.tsx +136 -0
  11. package/apps/lib/components/DataViews/KanbanView.tsx +264 -153
  12. package/apps/lib/components/DataViews/PanelControls.tsx +10 -41
  13. package/apps/lib/components/DataViews/TreeView.tsx +220 -191
  14. package/apps/lib/components/DataViews/index.ts +6 -0
  15. package/apps/lib/components/DataViews/types.ts +30 -1
  16. package/apps/lib/components/Radio.tsx +18 -21
  17. package/apps/lib/components/Switch.tsx +3 -1
  18. package/apps/lib/components/Table.tsx +1 -1
  19. package/apps/lib/components/TreeFolder/TreeFolder.tsx +160 -137
  20. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +221 -93
  21. package/apps/lib/components/TreeFolder/types.ts +9 -0
  22. package/apps/lib/layouts/DataViewCard.tsx +76 -0
  23. package/dist/src/shared/copyComponentsRecursively.js +9 -1
  24. package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
  25. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
  26. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +9 -1
  27. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
  28. package/docs/components/data-views-config-panel.md +204 -0
  29. package/docs/components/data-views-layout.md +270 -0
  30. package/package.json +1 -1
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type ElementType, type ReactNode } from "react";
4
+ import { cn } from "../../utils/cn";
5
+ import { Divider } from "../Divider";
6
+ import { getByPath } from "../../utils/dataViews/pathUtils";
7
+ import { renderField } from "./fieldRenderers";
8
+ import type { DynamicRecord, FieldConfig } from "./types";
9
+
10
+ export interface InboxViewCardProps {
11
+ item: DynamicRecord;
12
+ rowFields?: FieldConfig[];
13
+ titleField?: FieldConfig;
14
+ previewField?: FieldConfig;
15
+ detailField?: FieldConfig;
16
+ metaFields?: FieldConfig[];
17
+ dateField?: FieldConfig;
18
+ dateLabel?: string;
19
+ selected?: boolean;
20
+ onSelect?: () => void;
21
+ href?: string;
22
+ /**
23
+ * Component used to render the link when `href` is set. Defaults to a plain
24
+ * `<a>` so the component stays framework-agnostic. Pass your router's link
25
+ * (e.g. Next.js `Link`, React Router `Link`) for client-side navigation.
26
+ */
27
+ linkComponent?: ElementType;
28
+ className?: string;
29
+ }
30
+
31
+ function pickRowFields(props: InboxViewCardProps): FieldConfig[] {
32
+ if (props.rowFields && props.rowFields.length) return props.rowFields;
33
+ const collected: FieldConfig[] = [];
34
+ if (props.previewField) collected.push(props.previewField);
35
+ if (props.titleField && props.titleField.path !== props.previewField?.path) {
36
+ collected.push(props.titleField);
37
+ }
38
+ if (props.detailField) collected.push(props.detailField);
39
+ if (props.metaFields?.length) collected.push(...props.metaFields);
40
+ return collected;
41
+ }
42
+
43
+ function pickDateField(
44
+ rowFields: FieldConfig[],
45
+ explicit?: FieldConfig,
46
+ ): FieldConfig | undefined {
47
+ if (explicit) return explicit;
48
+ return rowFields.find((f) => f.type === "date");
49
+ }
50
+
51
+ export const InboxViewCard = forwardRef<HTMLDivElement, InboxViewCardProps>(
52
+ (props, ref) => {
53
+ const { item, selected = false, onSelect, href, linkComponent, className } =
54
+ props;
55
+ const allRowFields = pickRowFields(props);
56
+ const dateField = pickDateField(allRowFields, props.dateField);
57
+ const rowFields = dateField
58
+ ? allRowFields.filter((f) => f.path !== dateField.path)
59
+ : allRowFields;
60
+ const dateLabel = props.dateLabel ?? "Created at:";
61
+
62
+ const cardClass = cn(
63
+ "flex flex-col gap-2 p-3 cursor-pointer transition-colors",
64
+ "bg-background-presentation-form-base",
65
+ "border-y-2 border-transparent",
66
+ !selected &&
67
+ "hover:bg-[image:linear-gradient(0deg,rgba(151,72,255,0.05)_0%,rgba(151,72,255,0.05)_100%)] hover:border-y-[#AE71FF]",
68
+ selected &&
69
+ "bg-[image:linear-gradient(0deg,rgba(0,117,255,0.05)_0%,rgba(0,117,255,0.05)_100%)] border-y-border-presentation-state-focus",
70
+ className,
71
+ );
72
+
73
+ const content: ReactNode = (
74
+ <>
75
+ <div className="flex flex-col gap-1 w-full">
76
+ {rowFields.map((field, idx) => {
77
+ const value = getByPath(item, field.path);
78
+ return (
79
+ <div key={field.path ?? idx} className="flex flex-col">
80
+ <div className="flex items-center gap-2">
81
+ <span className="w-[100px] shrink-0 typography-body-large-semibold text-content-presentation-global-secondary">
82
+ {field.label ?? field.path}:
83
+ </span>
84
+ <span className="h-full py-0.5 flex items-center">
85
+ <span className="block h-full w-px bg-black-alpha-15" />
86
+ </span>
87
+ <span className="flex-1 min-w-0 truncate typography-body-large-medium text-content-presentation-global-primary">
88
+ {renderField(value, field, item)}
89
+ </span>
90
+ </div>
91
+ {idx < rowFields.length - 1 && <Divider className="mt-1" />}
92
+ </div>
93
+ );
94
+ })}
95
+ </div>
96
+
97
+ {dateField && (
98
+ <div className="flex items-center justify-end">
99
+ <div className="inline-flex items-center gap-0.5 p-0.5 rounded-md bg-black-alpha-10">
100
+ <div className="px-1 rounded-sm">
101
+ <span className="typography-labels-medium-semibold text-content-presentation-global-primary">
102
+ {dateLabel}
103
+ </span>
104
+ </div>
105
+ <div className="px-1 rounded-sm bg-black-alpha-075">
106
+ <span className="typography-labels-medium-semibold text-content-presentation-global-primary">
107
+ {renderField(getByPath(item, dateField.path), dateField, item)}
108
+ </span>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ )}
113
+ </>
114
+ );
115
+
116
+ if (href) {
117
+ const LinkTag = (linkComponent ?? "a") as ElementType;
118
+ return (
119
+ <LinkTag
120
+ href={href}
121
+ className={cn(cardClass, "no-underline text-inherit")}
122
+ >
123
+ {content}
124
+ </LinkTag>
125
+ );
126
+ }
127
+
128
+ return (
129
+ <div ref={ref} onClick={onSelect} className={cardClass}>
130
+ {content}
131
+ </div>
132
+ );
133
+ },
134
+ );
135
+
136
+ InboxViewCard.displayName = "InboxViewCard";
@@ -1,55 +1,86 @@
1
- "use client"
1
+ "use client";
2
2
 
3
- import type React from "react"
4
- import { useMemo, useState } from "react"
5
- import { Badge } from "../Badge"
6
- import { Plus, MoreHorizontal } from "lucide-react"
3
+ import type React from "react";
4
+ import { Fragment, useMemo, useState } from "react";
5
+ import { MoreHorizontal } from "lucide-react";
7
6
  import type {
8
7
  DynamicRecord,
9
8
  ViewConfig,
10
9
  DynamicColumnConfig,
11
10
  FieldConfig,
12
- } from "./types"
13
- import { Button } from "../Button"
14
- import { Card, CardContent, CardHeader } from "../Card"
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 { resolveBadgeVariant } from "./badgeAdapter"
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
20
 
21
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
- }
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
+ };
29
65
 
30
66
  type KanbanColumn = {
31
- id: string
32
- title: string
33
- color: string
34
- items: DynamicRecord[]
35
- }
67
+ id: string;
68
+ title: string;
69
+ color: ColumnColor;
70
+ items: DynamicRecord[];
71
+ };
36
72
 
37
- const COLUMN_COLORS = [
38
- "bg-background-presentation-badge-gray-primary",
39
- "bg-background-presentation-badge-blue-primary",
40
- "bg-background-presentation-badge-purple-primary",
41
- "bg-background-presentation-badge-success-primary",
42
- "bg-background-presentation-badge-yellow-primary",
43
- "bg-background-presentation-badge-red-primary",
44
- ]
45
-
46
- function getId(item: DynamicRecord, fallbackPath: string | undefined, idx: number): any {
47
- if (item?.id != null) return item.id
73
+ function getId(
74
+ item: DynamicRecord,
75
+ fallbackPath: string | undefined,
76
+ idx: number,
77
+ ): any {
78
+ if (item?.id != null) return item.id;
48
79
  if (fallbackPath) {
49
- const v = getByPath(item, fallbackPath)
50
- if (v != null) return v
80
+ const v = getByPath(item, fallbackPath);
81
+ if (v != null) return v;
51
82
  }
52
- return idx
83
+ return idx;
53
84
  }
54
85
 
55
86
  export function KanbanView({
@@ -57,124 +88,184 @@ export function KanbanView({
57
88
  fields,
58
89
  onDataUpdate,
59
90
  groupByField = "status",
91
+ titleField,
92
+ onColumnAction,
60
93
  }: KanbanViewProps) {
61
- const isMobile = useIsMobile()
62
- const [draggedItem, setDraggedItem] = useState<{ item: DynamicRecord; columnId: string } | null>(null)
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);
63
100
 
64
101
  const displayFields = useMemo(
65
102
  () => visibleFields(fields).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
66
103
  [fields],
67
- )
104
+ );
68
105
 
69
106
  const groupField = useMemo(
70
107
  () => fields.find((f) => f.path === groupByField),
71
108
  [fields, groupByField],
72
- )
109
+ );
73
110
 
74
111
  const kanbanColumns = useMemo<KanbanColumn[]>(() => {
75
- const groups: Record<string, 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
+ });
76
124
 
77
125
  if (groupField?.variants) {
78
126
  Object.keys(groupField.variants).forEach((value, index) => {
79
127
  groups[value] = {
80
128
  id: value,
81
- title: value,
82
- color: COLUMN_COLORS[index % COLUMN_COLORS.length],
129
+ ...resolve(value, index),
83
130
  items: [],
84
- }
85
- })
131
+ };
132
+ });
86
133
  }
87
134
 
88
- let nextColorIdx = Object.keys(groups).length
89
135
  for (const item of data) {
90
- const value = String(getByPath(item, groupByField) ?? "Uncategorized")
136
+ const value = String(getByPath(item, groupByField) ?? "Uncategorized");
91
137
  if (!groups[value]) {
92
138
  groups[value] = {
93
139
  id: value,
94
- title: value,
95
- color: COLUMN_COLORS[nextColorIdx++ % COLUMN_COLORS.length],
140
+ ...resolve(value, colorIndexFor(value)),
96
141
  items: [],
97
- }
142
+ };
98
143
  }
99
- groups[value].items.push(item)
144
+ groups[value].items.push(item);
100
145
  }
101
146
 
102
- return Object.values(groups)
103
- }, [data, groupByField, groupField])
147
+ return Object.values(groups);
148
+ }, [data, groupByField, groupField]);
104
149
 
105
150
  const handleDragStart = (item: DynamicRecord, columnId: string) => {
106
- setDraggedItem({ item, columnId })
107
- }
151
+ setDraggedItem({ item, columnId });
152
+ };
108
153
 
109
- const handleDragOver = (e: React.DragEvent) => {
110
- e.preventDefault()
111
- }
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
+ };
112
171
 
113
- const idPath = displayFields[0]?.path
172
+ const idPath = displayFields[0]?.path;
114
173
 
115
174
  const handleDrop = (targetColumnId: string) => {
116
- if (!draggedItem) return
117
- const draggedId = getId(draggedItem.item, idPath, -1)
175
+ if (!draggedItem) {
176
+ setDragOverColumnId(null);
177
+ return;
178
+ }
179
+ const draggedId = getId(draggedItem.item, idPath, -1);
118
180
 
119
181
  const updatedData = data.map((item, idx) => {
120
- const itemId = getId(item, idPath, idx)
182
+ const itemId = getId(item, idPath, idx);
121
183
  if (itemId === draggedId) {
122
- return setByPath(item, groupByField, targetColumnId)
184
+ return setByPath(item, groupByField, targetColumnId);
123
185
  }
124
- return item
125
- })
186
+ return item;
187
+ });
126
188
 
127
- onDataUpdate?.(updatedData)
128
- setDraggedItem(null)
129
- }
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]);
130
200
 
131
201
  const renderCard = (item: DynamicRecord, idx: number) => {
132
- const itemId = getId(item, idPath, idx)
133
- const titleField = displayFields[0]
134
- const descField = displayFields[1]
135
- const titleValue = titleField ? getByPath(item, titleField.path) : ""
136
- const descValue = descField ? getByPath(item, descField.path) : null
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
+ }
137
240
 
138
241
  return (
139
- <Card
242
+ <DataViewCard
140
243
  key={itemId}
141
244
  draggable={!isMobile}
142
- onDragStart={!isMobile ? () => handleDragStart(item, String(getByPath(item, groupByField) ?? "Uncategorized")) : undefined}
143
- className={isMobile ? "cursor-pointer" : "cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"}
144
- >
145
- <CardHeader>
146
- <div className="flex items-start justify-between gap-2">
147
- <div className="flex-1">
148
- {titleField && renderField(titleValue, titleField, item)}
149
- </div>
150
- <Button variant="BorderStyle" buttonType="icon" className="h-6 w-6 -mt-1 -mr-1">
151
- <MoreHorizontal className="h-3 w-3" />
152
- </Button>
153
- </div>
154
- {descField && descValue != null && (
155
- <div className="text-xs text-content-presentation-global-tertiary leading-relaxed">
156
- {renderField(descValue, descField, item)}
157
- </div>
158
- )}
159
- </CardHeader>
160
- <CardContent className="space-y-3 pt-0">
161
- <div className="space-y-2">
162
- {displayFields.slice(2).map((field) => {
163
- if (field.path === groupByField) return null
164
- const value = getByPath(item, field.path)
165
- if (value == null) return null
166
- return (
167
- <div key={field.path} className="flex items-center justify-between text-xs">
168
- <span className="text-content-presentation-global-tertiary">{field.label}:</span>
169
- {renderField(value, field, item)}
170
- </div>
171
- )
172
- })}
173
- </div>
174
- </CardContent>
175
- </Card>
176
- )
177
- }
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
+ };
178
269
 
179
270
  if (isMobile) {
180
271
  return (
@@ -182,7 +273,7 @@ export function KanbanView({
182
273
  <div className="flex flex-col gap-4">
183
274
  {kanbanColumns.map((column) => (
184
275
  <div key={column.id} className="flex flex-col gap-3">
185
- <ColumnHeader column={column} />
276
+ <ColumnHeader column={column} onAction={onColumnAction} />
186
277
  <div className="flex flex-col gap-3">
187
278
  {column.items.map((item, idx) => renderCard(item, idx))}
188
279
  </div>
@@ -190,53 +281,73 @@ export function KanbanView({
190
281
  ))}
191
282
  </div>
192
283
  </div>
193
- )
284
+ );
194
285
  }
195
286
 
196
287
  return (
197
- <div className="h-full overflow-x-auto p-6 bg-background-presentation-body-primary">
198
- <div className="flex h-full gap-4 pb-4" style={{ minWidth: "max-content" }}>
199
- {kanbanColumns.map((column) => (
200
- <div
201
- key={column.id}
202
- className="flex w-80 flex-col gap-3"
203
- onDragOver={handleDragOver}
204
- onDrop={() => handleDrop(column.id)}
205
- >
206
- <ColumnHeader column={column} />
207
- <div className="flex flex-col gap-3 overflow-y-auto">
208
- {column.items.map((item, idx) => renderCard(item, idx))}
209
- </div>
210
- </div>
211
- ))}
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
+ })}
212
319
  </div>
213
320
  </div>
214
- )
321
+ );
215
322
  }
216
323
 
217
- function ColumnHeader({ column }: { column: KanbanColumn }) {
218
- const countBadge = resolveBadgeVariant("gray")
324
+ function ColumnHeader({
325
+ column,
326
+ onAction,
327
+ }: {
328
+ column: KanbanColumn;
329
+ onAction?: (columnId: string) => void;
330
+ }) {
219
331
  return (
220
- <div className="flex items-center justify-between rounded-lg p-3 border border-border-presentation-global-primary bg-background-presentation-body-overlay-primary">
221
- <div className="flex items-center gap-2">
222
- <div className={`h-2 w-2 rounded-full ${column.color}`} />
223
- <h3 className="font-semibold text-content-presentation-global-primary">{column.title}</h3>
224
- <Badge
225
- {...countBadge}
226
- label={String(column.items.length)}
227
- className="h-5 rounded-full p-0 text-xs"
228
- size="XS"
229
-
230
- />
231
- </div>
232
- <div className="flex items-center gap-1">
233
- <Button variant="BorderStyle" buttonType="icon" className="h-7 w-7">
234
- <Plus className="h-4 w-4" />
235
- </Button>
236
- <Button variant="BorderStyle" buttonType="icon" className="h-7 w-7">
237
- <MoreHorizontal className="h-4 w-4" />
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" />
238
349
  </Button>
239
- </div>
350
+ )}
240
351
  </div>
241
- )
352
+ );
242
353
  }