@schandlergarcia/sf-web-components 2.4.0 → 2.5.0

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.
@@ -0,0 +1,507 @@
1
+ import React, { useMemo, useState, useCallback } from "react";
2
+ import {
3
+ DndContext,
4
+ DragOverlay,
5
+ PointerSensor,
6
+ KeyboardSensor,
7
+ useSensor,
8
+ useSensors,
9
+ closestCenter,
10
+ useDroppable,
11
+ } from "@dnd-kit/core";
12
+ import {
13
+ SortableContext,
14
+ useSortable,
15
+ verticalListSortingStrategy,
16
+ sortableKeyboardCoordinates,
17
+ arrayMove,
18
+ } from "@dnd-kit/sortable";
19
+ import { CSS } from "@dnd-kit/utilities";
20
+ import BaseCard from "./BaseCard";
21
+ import UIText from "../ui/Text";
22
+ import UIChip from "../ui/Chip";
23
+
24
+ /**
25
+ * KanbanBoard
26
+ *
27
+ * A swim-lane / board view with structured cards and (optional) drag-and-drop
28
+ * powered by @dnd-kit. Designed to live inside a CommandCenter dashboard and
29
+ * to behave consistently with ListCard / TableCard.
30
+ *
31
+ * Data shapes:
32
+ * columns: [{ id, title, accent?, limit?, footer?, emptyMessage? }]
33
+ * cards: [{ id, columnId, title, subtitle?, description?, badges?,
34
+ * status?, avatar?, meta?, actions?, accent? }]
35
+ *
36
+ * Callbacks:
37
+ * onCardClick(card)
38
+ * onCardMove({ cardId, fromColumnId, toColumnId, fromIndex, toIndex })
39
+ */
40
+
41
+ const ACCENT_RING = {
42
+ default: "ring-slate-200 dark:ring-slate-800",
43
+ primary: "ring-brand-500/40",
44
+ success: "ring-emerald-500/40",
45
+ warning: "ring-amber-500/40",
46
+ danger: "ring-rose-500/40",
47
+ info: "ring-sky-500/40",
48
+ };
49
+
50
+ const ACCENT_DOT = {
51
+ default: "bg-slate-400",
52
+ primary: "bg-brand-500",
53
+ success: "bg-emerald-500",
54
+ warning: "bg-amber-500",
55
+ danger: "bg-rose-500",
56
+ info: "bg-sky-500",
57
+ };
58
+
59
+ const ACCENT_BORDER_LEFT = {
60
+ default: "border-l-slate-300 dark:border-l-slate-700",
61
+ primary: "border-l-brand-500",
62
+ success: "border-l-emerald-500",
63
+ warning: "border-l-amber-500",
64
+ danger: "border-l-rose-500",
65
+ info: "border-l-sky-500",
66
+ };
67
+
68
+ function CardBadges({ badges }) {
69
+ if (!badges || badges.length === 0) return null;
70
+ return (
71
+ <div className="mt-2 flex flex-wrap gap-1.5">
72
+ {badges.map((b, i) => {
73
+ if (React.isValidElement(b)) return <span key={i}>{b}</span>;
74
+ if (typeof b === "string") {
75
+ return (
76
+ <UIChip key={i} size="sm" variant="default">
77
+ {b}
78
+ </UIChip>
79
+ );
80
+ }
81
+ return (
82
+ <UIChip
83
+ key={b.id ?? i}
84
+ size={b.size ?? "sm"}
85
+ variant={b.variant ?? "default"}
86
+ color={b.color}
87
+ >
88
+ {b.label ?? b.text}
89
+ </UIChip>
90
+ );
91
+ })}
92
+ </div>
93
+ );
94
+ }
95
+
96
+ function CardAvatar({ avatar, title }) {
97
+ if (!avatar) return null;
98
+ if (typeof avatar === "string") {
99
+ return (
100
+ <img
101
+ src={avatar}
102
+ alt=""
103
+ className="h-7 w-7 shrink-0 rounded-full border border-slate-200 object-cover dark:border-slate-800"
104
+ />
105
+ );
106
+ }
107
+ if (React.isValidElement(avatar)) {
108
+ return (
109
+ <div className="grid h-7 w-7 shrink-0 place-items-center rounded-full border border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-800">
110
+ {avatar}
111
+ </div>
112
+ );
113
+ }
114
+ return (
115
+ <div className="grid h-7 w-7 shrink-0 place-items-center rounded-full border border-slate-200 bg-slate-100 text-[10px] font-semibold text-slate-700 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-200">
116
+ {String(title ?? "??")
117
+ .slice(0, 2)
118
+ .toUpperCase()}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ function KanbanCardBody({ card, accent, dragging, onClick, dense }) {
124
+ const accentBorder =
125
+ ACCENT_BORDER_LEFT[card?.accent ?? accent ?? "default"] ?? ACCENT_BORDER_LEFT.default;
126
+
127
+ const Comp = onClick ? "button" : "div";
128
+
129
+ return (
130
+ <Comp
131
+ type={onClick ? "button" : undefined}
132
+ onClick={onClick ? () => onClick(card) : undefined}
133
+ className={[
134
+ "group block w-full rounded-xl border bg-white text-left shadow-sm transition",
135
+ "border-slate-200 dark:border-slate-800 dark:bg-slate-900",
136
+ "border-l-4",
137
+ accentBorder,
138
+ dense ? "p-2.5" : "p-3",
139
+ dragging ? "opacity-60 ring-2 ring-brand-500" : "hover:-translate-y-[1px] hover:shadow-md",
140
+ ]
141
+ .filter(Boolean)
142
+ .join(" ")}
143
+ >
144
+ <div className="flex items-start gap-2">
145
+ <CardAvatar avatar={card.avatar} title={card.title} />
146
+ <div className="min-w-0 flex-1">
147
+ <UIText as="div" size="sm" weight="medium" className="truncate">
148
+ {card.title}
149
+ </UIText>
150
+ {card.subtitle ? (
151
+ <UIText as="div" size="xs" muted className="mt-0.5 truncate">
152
+ {card.subtitle}
153
+ </UIText>
154
+ ) : null}
155
+ </div>
156
+ {card.status ? (
157
+ <span
158
+ aria-label={String(card.status)}
159
+ className={[
160
+ "mt-1.5 h-2 w-2 shrink-0 rounded-full",
161
+ ACCENT_DOT[card.status] ?? ACCENT_DOT.default,
162
+ ].join(" ")}
163
+ />
164
+ ) : null}
165
+ </div>
166
+
167
+ {card.description ? (
168
+ <UIText as="div" size="xs" muted className="mt-2 line-clamp-3 text-left">
169
+ {card.description}
170
+ </UIText>
171
+ ) : null}
172
+
173
+ <CardBadges badges={card.badges} />
174
+
175
+ {card.meta && card.meta.length > 0 ? (
176
+ <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
177
+ {card.meta.map((m, i) => {
178
+ if (React.isValidElement(m)) return <span key={i}>{m}</span>;
179
+ if (typeof m === "string") return <span key={i}>{m}</span>;
180
+ return (
181
+ <span key={m.id ?? i} className="inline-flex items-center gap-1">
182
+ {m.icon ? <span className="opacity-70">{m.icon}</span> : null}
183
+ <span>{m.label ?? m.text}</span>
184
+ </span>
185
+ );
186
+ })}
187
+ </div>
188
+ ) : null}
189
+
190
+ {card.actions ? (
191
+ <div
192
+ className="mt-2 flex justify-end gap-1.5"
193
+ onClick={(e) => e.stopPropagation()}
194
+ >
195
+ {card.actions}
196
+ </div>
197
+ ) : null}
198
+ </Comp>
199
+ );
200
+ }
201
+
202
+ function SortableKanbanCard({ card, accent, onClick, isDraggable, dense }) {
203
+ const sortable = useSortable({
204
+ id: card.id,
205
+ data: { columnId: card.columnId, type: "card" },
206
+ disabled: !isDraggable,
207
+ });
208
+
209
+ const style = {
210
+ transform: CSS.Translate.toString(sortable.transform),
211
+ transition: sortable.transition,
212
+ };
213
+
214
+ return (
215
+ <div
216
+ ref={sortable.setNodeRef}
217
+ style={style}
218
+ {...(isDraggable ? sortable.attributes : {})}
219
+ {...(isDraggable ? sortable.listeners : {})}
220
+ className={isDraggable ? "touch-none" : undefined}
221
+ >
222
+ <KanbanCardBody
223
+ card={card}
224
+ accent={accent}
225
+ dragging={sortable.isDragging}
226
+ onClick={onClick}
227
+ dense={dense}
228
+ />
229
+ </div>
230
+ );
231
+ }
232
+
233
+ function KanbanColumn({
234
+ column,
235
+ cards,
236
+ accent,
237
+ onCardClick,
238
+ isDraggable,
239
+ dense,
240
+ showCounts,
241
+ showLimits,
242
+ emptyMessage,
243
+ }) {
244
+ const droppable = useDroppable({
245
+ id: `column:${column.id}`,
246
+ data: { columnId: column.id, type: "column" },
247
+ });
248
+
249
+ const count = cards.length;
250
+ const overLimit = column.limit != null && count > column.limit;
251
+
252
+ return (
253
+ <div className="flex w-72 shrink-0 flex-col rounded-2xl border border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900/60">
254
+ <div className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-800">
255
+ <div className="flex min-w-0 items-center gap-2">
256
+ <span
257
+ aria-hidden
258
+ className={[
259
+ "h-2 w-2 shrink-0 rounded-full",
260
+ ACCENT_DOT[column.accent ?? accent ?? "default"] ?? ACCENT_DOT.default,
261
+ ].join(" ")}
262
+ />
263
+ <UIText as="div" size="sm" weight="medium" className="truncate">
264
+ {column.title}
265
+ </UIText>
266
+ {showCounts ? (
267
+ <UIChip
268
+ size="sm"
269
+ variant="default"
270
+ className={overLimit ? "ring-1 ring-rose-500" : undefined}
271
+ >
272
+ {showLimits && column.limit != null ? `${count} / ${column.limit}` : count}
273
+ </UIChip>
274
+ ) : null}
275
+ </div>
276
+ {column.actions ? <div className="shrink-0">{column.actions}</div> : null}
277
+ </div>
278
+
279
+ <div
280
+ ref={droppable.setNodeRef}
281
+ className={[
282
+ "flex flex-1 flex-col gap-2 overflow-y-auto p-2",
283
+ droppable.isOver ? "bg-brand-50/50 dark:bg-brand-950/30" : "",
284
+ ]
285
+ .filter(Boolean)
286
+ .join(" ")}
287
+ style={{ minHeight: 80 }}
288
+ >
289
+ <SortableContext
290
+ items={cards.map((c) => c.id)}
291
+ strategy={verticalListSortingStrategy}
292
+ >
293
+ {cards.length === 0 ? (
294
+ <div className="grid flex-1 place-items-center px-2 py-6">
295
+ <UIText as="div" size="xs" muted className="text-center">
296
+ {column.emptyMessage ?? emptyMessage ?? "No items"}
297
+ </UIText>
298
+ </div>
299
+ ) : (
300
+ cards.map((card) => (
301
+ <SortableKanbanCard
302
+ key={card.id}
303
+ card={card}
304
+ accent={accent}
305
+ onClick={onCardClick}
306
+ isDraggable={isDraggable}
307
+ dense={dense}
308
+ />
309
+ ))
310
+ )}
311
+ </SortableContext>
312
+ </div>
313
+
314
+ {column.footer ? (
315
+ <div className="border-t border-slate-200 px-3 py-2 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
316
+ {column.footer}
317
+ </div>
318
+ ) : null}
319
+ </div>
320
+ );
321
+ }
322
+
323
+ export default function KanbanBoard({
324
+ columns = [],
325
+ cards = [],
326
+ title,
327
+ subtitle,
328
+ accent = "default",
329
+ isDraggable = true,
330
+ dense = false,
331
+ showColumnCounts = true,
332
+ showWipLimits = true,
333
+ emptyMessage = "No items",
334
+ loading = false,
335
+ error,
336
+ actions,
337
+ onCardClick,
338
+ onCardMove,
339
+ className = "",
340
+ ...cardProps
341
+ }) {
342
+ const [activeId, setActiveId] = useState(null);
343
+ const [internalCards, setInternalCards] = useState(null);
344
+
345
+ const liveCards = internalCards ?? cards;
346
+
347
+ const sensors = useSensors(
348
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
349
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
350
+ );
351
+
352
+ const cardsByColumn = useMemo(() => {
353
+ const map = {};
354
+ for (const col of columns) map[col.id] = [];
355
+ for (const c of liveCards) {
356
+ const colId = c.columnId;
357
+ if (!map[colId]) map[colId] = [];
358
+ map[colId].push(c);
359
+ }
360
+ return map;
361
+ }, [columns, liveCards]);
362
+
363
+ const findColumnIdForCard = useCallback(
364
+ (cardId) => {
365
+ const card = liveCards.find((c) => c.id === cardId);
366
+ return card?.columnId ?? null;
367
+ },
368
+ [liveCards]
369
+ );
370
+
371
+ const resolveColumnId = useCallback(
372
+ (id) => {
373
+ if (!id) return null;
374
+ if (typeof id === "string" && id.startsWith("column:")) return id.slice("column:".length);
375
+ const card = liveCards.find((c) => c.id === id);
376
+ return card?.columnId ?? null;
377
+ },
378
+ [liveCards]
379
+ );
380
+
381
+ const handleDragStart = (event) => setActiveId(event.active.id);
382
+
383
+ const handleDragEnd = (event) => {
384
+ setActiveId(null);
385
+ const { active, over } = event;
386
+ if (!over) return;
387
+
388
+ const fromColumnId = findColumnIdForCard(active.id);
389
+ const toColumnId = resolveColumnId(over.id);
390
+ if (!fromColumnId || !toColumnId) return;
391
+
392
+ const fromList = (cardsByColumn[fromColumnId] ?? []).slice();
393
+ const toList =
394
+ fromColumnId === toColumnId ? fromList : (cardsByColumn[toColumnId] ?? []).slice();
395
+
396
+ const fromIndex = fromList.findIndex((c) => c.id === active.id);
397
+ let toIndex;
398
+
399
+ if (over.data?.current?.type === "column" || over.id === `column:${toColumnId}`) {
400
+ toIndex = toList.length;
401
+ } else {
402
+ toIndex = toList.findIndex((c) => c.id === over.id);
403
+ if (toIndex === -1) toIndex = toList.length;
404
+ }
405
+
406
+ if (fromColumnId === toColumnId && fromIndex === toIndex) return;
407
+
408
+ let nextCards;
409
+ if (fromColumnId === toColumnId) {
410
+ const reordered = arrayMove(fromList, fromIndex, toIndex);
411
+ const others = liveCards.filter((c) => c.columnId !== fromColumnId);
412
+ nextCards = [...others, ...reordered];
413
+ } else {
414
+ const moved = { ...fromList[fromIndex], columnId: toColumnId };
415
+ const newFrom = fromList.filter((c) => c.id !== active.id);
416
+ const newTo = toList.slice();
417
+ newTo.splice(toIndex, 0, moved);
418
+ const others = liveCards.filter(
419
+ (c) => c.columnId !== fromColumnId && c.columnId !== toColumnId
420
+ );
421
+ nextCards = [...others, ...newFrom, ...newTo];
422
+ }
423
+
424
+ if (onCardMove) {
425
+ onCardMove({
426
+ cardId: active.id,
427
+ fromColumnId,
428
+ toColumnId,
429
+ fromIndex,
430
+ toIndex,
431
+ });
432
+ } else {
433
+ setInternalCards(nextCards);
434
+ }
435
+ };
436
+
437
+ const activeCard = activeId ? liveCards.find((c) => c.id === activeId) : null;
438
+
439
+ const header =
440
+ title || subtitle || actions ? (
441
+ <div className="mb-3 flex items-start justify-between gap-3">
442
+ <div className="min-w-0">
443
+ {title ? (
444
+ <UIText as="div" size="sm" weight="medium">
445
+ {title}
446
+ </UIText>
447
+ ) : null}
448
+ {subtitle ? (
449
+ <UIText as="div" size="xs" muted className="mt-1">
450
+ {subtitle}
451
+ </UIText>
452
+ ) : null}
453
+ </div>
454
+ {actions ? <div className="shrink-0">{actions}</div> : null}
455
+ </div>
456
+ ) : null;
457
+
458
+ const board = (
459
+ <div className="flex gap-3 overflow-x-auto pb-1">
460
+ {columns.map((col) => (
461
+ <KanbanColumn
462
+ key={col.id}
463
+ column={col}
464
+ cards={cardsByColumn[col.id] ?? []}
465
+ accent={accent}
466
+ onCardClick={onCardClick}
467
+ isDraggable={isDraggable}
468
+ dense={dense}
469
+ showCounts={showColumnCounts}
470
+ showLimits={showWipLimits}
471
+ emptyMessage={emptyMessage}
472
+ />
473
+ ))}
474
+ </div>
475
+ );
476
+
477
+ return (
478
+ <BaseCard
479
+ padding="default"
480
+ isLoading={loading}
481
+ className={className}
482
+ {...cardProps}
483
+ >
484
+ {header}
485
+ {error ? (
486
+ <div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300">
487
+ {String(error)}
488
+ </div>
489
+ ) : (
490
+ <DndContext
491
+ sensors={sensors}
492
+ collisionDetection={closestCenter}
493
+ onDragStart={handleDragStart}
494
+ onDragEnd={handleDragEnd}
495
+ onDragCancel={() => setActiveId(null)}
496
+ >
497
+ {board}
498
+ <DragOverlay>
499
+ {activeCard ? (
500
+ <KanbanCardBody card={activeCard} accent={accent} dragging dense={dense} />
501
+ ) : null}
502
+ </DragOverlay>
503
+ </DndContext>
504
+ )}
505
+ </BaseCard>
506
+ );
507
+ }
@@ -29,6 +29,7 @@ export { default as ActivityCard } from "./cards/ActivityCard";
29
29
  export { default as MetricsStrip } from "./cards/MetricsStrip";
30
30
  export { default as CalloutCard } from "./cards/CalloutCard";
31
31
  export { default as ActionList } from "./cards/ActionList";
32
+ export { default as KanbanBoard } from "./cards/KanbanBoard";
32
33
 
33
34
  // Charts
34
35
  export { default as D3Chart } from "./charts/D3Chart";