Skip to content

Commit 45c014d

Browse files
feat(kanban): implement interactive Kanban preview with drag-and-drop functionality
- Introduced KanbanCard and KanbanColumn interfaces for structured data representation. - Added INITIAL_COLUMNS for initial state setup with predefined cards. - Developed KanbanPreview component featuring HTML5 drag-and-drop capabilities. - Implemented drag-and-drop event handlers for card movement between columns. - Enhanced visual feedback during drag operations with styling updates. - Created comprehensive E2E tests to validate rendering, drag-and-drop functionality, and accessibility.
1 parent cc82607 commit 45c014d

File tree

2 files changed

+558
-36
lines changed

2 files changed

+558
-36
lines changed

app/page.tsx

Lines changed: 220 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -245,36 +245,203 @@ const Navigation = () => {
245245
)
246246
}
247247

248-
// Kanban Preview Component (Visual illustration)
248+
/**
249+
* Card data structure for the Kanban preview.
250+
*/
251+
interface KanbanCard {
252+
/** Unique identifier for the card */
253+
id: string
254+
/** Display name of the card */
255+
name: string
256+
/** Tailwind CSS classes for background and border colors */
257+
color: string
258+
}
259+
260+
/**
261+
* Column data structure for the Kanban preview.
262+
*/
263+
interface KanbanColumn {
264+
/** Unique identifier for the column */
265+
id: string
266+
/** Display title of the column */
267+
title: string
268+
/** Cards contained in this column */
269+
cards: KanbanCard[]
270+
}
271+
272+
/**
273+
* Initial columns data for the Kanban preview.
274+
* Includes Backlog (3 cards), In Progress (1), Review (1), Done (2).
275+
*/
276+
const INITIAL_COLUMNS: KanbanColumn[] = [
277+
{
278+
id: 'backlog',
279+
title: 'Backlog',
280+
cards: [
281+
{
282+
id: 'react',
283+
name: 'react',
284+
color: 'bg-blue-500/20 border-blue-500/30',
285+
},
286+
{
287+
id: 'vue',
288+
name: 'vue',
289+
color: 'bg-emerald-500/20 border-emerald-500/30',
290+
},
291+
{
292+
id: 'angular',
293+
name: 'angular',
294+
color: 'bg-orange-500/20 border-orange-500/30',
295+
},
296+
],
297+
},
298+
{
299+
id: 'in-progress',
300+
title: 'In Progress',
301+
cards: [
302+
{
303+
id: 'nextjs',
304+
name: 'next.js',
305+
color: 'bg-purple-500/20 border-purple-500/30',
306+
},
307+
],
308+
},
309+
{
310+
id: 'review',
311+
title: 'Review',
312+
cards: [
313+
{
314+
id: 'typescript',
315+
name: 'typescript',
316+
color: 'bg-amber-500/20 border-amber-500/30',
317+
},
318+
],
319+
},
320+
{
321+
id: 'done',
322+
title: 'Done',
323+
cards: [
324+
{
325+
id: 'tailwind',
326+
name: 'tailwind',
327+
color: 'bg-cyan-500/20 border-cyan-500/30',
328+
},
329+
{
330+
id: 'prisma',
331+
name: 'prisma',
332+
color: 'bg-rose-500/20 border-rose-500/30',
333+
},
334+
],
335+
},
336+
]
337+
338+
/**
339+
* Interactive Kanban Preview Component with HTML5 Drag & Drop.
340+
* Demonstrates drag-and-drop functionality for the landing page hero section.
341+
*/
249342
const KanbanPreview = () => {
250-
const columns = [
251-
{
252-
title: 'Backlog',
253-
cards: [
254-
{ name: 'react', color: 'bg-blue-500/20 border-blue-500/30' },
255-
{ name: 'vue', color: 'bg-emerald-500/20 border-emerald-500/30' },
256-
],
257-
},
258-
{
259-
title: 'In Progress',
260-
cards: [
261-
{ name: 'next.js', color: 'bg-purple-500/20 border-purple-500/30' },
262-
],
263-
},
264-
{
265-
title: 'Review',
266-
cards: [
267-
{ name: 'typescript', color: 'bg-amber-500/20 border-amber-500/30' },
268-
],
269-
},
270-
{
271-
title: 'Done',
272-
cards: [
273-
{ name: 'tailwind', color: 'bg-cyan-500/20 border-cyan-500/30' },
274-
{ name: 'prisma', color: 'bg-rose-500/20 border-rose-500/30' },
275-
],
276-
},
277-
]
343+
const [columns, setColumns] = useState<KanbanColumn[]>(INITIAL_COLUMNS)
344+
const [draggedCard, setDraggedCard] = useState<KanbanCard | null>(null)
345+
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null)
346+
347+
/**
348+
* Handles the start of a drag operation.
349+
* @param e - The drag event
350+
* @param card - The card being dragged
351+
* @param sourceColumnId - The ID of the column the card is being dragged from
352+
*/
353+
const handleDragStart = (
354+
e: React.DragEvent<HTMLDivElement>,
355+
card: KanbanCard,
356+
sourceColumnId: string,
357+
) => {
358+
setDraggedCard(card)
359+
e.dataTransfer.setData('cardId', card.id)
360+
e.dataTransfer.setData('sourceColumnId', sourceColumnId)
361+
e.dataTransfer.effectAllowed = 'move'
362+
363+
// Apply drag styling after a brief delay to ensure visual feedback
364+
requestAnimationFrame(() => {
365+
const target = e.target as HTMLElement
366+
target.style.opacity = '0.5'
367+
})
368+
}
369+
370+
/**
371+
* Handles the end of a drag operation.
372+
* @param e - The drag event
373+
*/
374+
const handleDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
375+
const target = e.target as HTMLElement
376+
target.style.opacity = '1'
377+
setDraggedCard(null)
378+
setDragOverColumn(null)
379+
}
380+
381+
/**
382+
* Handles drag over event for columns.
383+
* @param e - The drag event
384+
* @param columnId - The ID of the column being dragged over
385+
*/
386+
const handleDragOver = (
387+
e: React.DragEvent<HTMLDivElement>,
388+
columnId: string,
389+
) => {
390+
e.preventDefault()
391+
e.dataTransfer.dropEffect = 'move'
392+
setDragOverColumn(columnId)
393+
}
394+
395+
/**
396+
* Handles drag leave event for columns.
397+
*/
398+
const handleDragLeave = () => {
399+
setDragOverColumn(null)
400+
}
401+
402+
/**
403+
* Handles drop event for columns.
404+
* Moves the card from source column to target column.
405+
* @param e - The drag event
406+
* @param targetColumnId - The ID of the column where the card is dropped
407+
*/
408+
const handleDrop = (
409+
e: React.DragEvent<HTMLDivElement>,
410+
targetColumnId: string,
411+
) => {
412+
e.preventDefault()
413+
const cardId = e.dataTransfer.getData('cardId')
414+
const sourceColumnId = e.dataTransfer.getData('sourceColumnId')
415+
416+
if (sourceColumnId === targetColumnId) {
417+
setDragOverColumn(null)
418+
return
419+
}
420+
421+
setColumns((prevColumns) => {
422+
const newColumns = prevColumns.map((col) => ({
423+
...col,
424+
cards: [...col.cards],
425+
}))
426+
427+
const sourceColumn = newColumns.find((col) => col.id === sourceColumnId)
428+
const targetColumn = newColumns.find((col) => col.id === targetColumnId)
429+
430+
if (!sourceColumn || !targetColumn) return prevColumns
431+
432+
const cardIndex = sourceColumn.cards.findIndex(
433+
(card) => card.id === cardId,
434+
)
435+
if (cardIndex === -1) return prevColumns
436+
437+
const [movedCard] = sourceColumn.cards.splice(cardIndex, 1)
438+
targetColumn.cards.push(movedCard)
439+
440+
return newColumns
441+
})
442+
443+
setDragOverColumn(null)
444+
}
278445

279446
return (
280447
<div className="relative w-full max-w-4xl mx-auto mt-16">
@@ -299,29 +466,46 @@ const KanbanPreview = () => {
299466
<div className="p-6 grid grid-cols-4 gap-4">
300467
{columns.map((column, idx) => (
301468
<div
302-
key={column.title}
303-
className="space-y-3"
469+
key={column.id}
470+
data-testid={`kanban-column-${column.id}`}
471+
className={cn(
472+
'p-3 rounded-lg border border-border/40 shadow-sm bg-muted/20',
473+
'transition-all duration-200',
474+
dragOverColumn === column.id &&
475+
'border-primary/50 bg-primary/5 shadow-md',
476+
)}
477+
onDragOver={(e) => handleDragOver(e, column.id)}
478+
onDragLeave={handleDragLeave}
479+
onDrop={(e) => handleDrop(e, column.id)}
304480
style={{
305481
animation: `fadeInUp 0.5s ease-out ${idx * 0.1}s forwards`,
306482
opacity: 0,
307483
}}
308484
>
309-
<div className="flex items-center justify-between">
485+
<div className="flex items-center justify-between mb-3">
310486
<span className="text-xs font-medium text-muted-foreground">
311487
{column.title}
312488
</span>
313-
<span className="text-xs text-muted-foreground/60">
489+
<span
490+
className="text-xs text-muted-foreground/60"
491+
data-testid={`column-count-${column.id}`}
492+
>
314493
{column.cards.length}
315494
</span>
316495
</div>
317-
<div className="space-y-2">
496+
<div className="space-y-2 min-h-[60px]">
318497
{column.cards.map((card, cardIdx) => (
319498
<div
320-
key={card.name}
499+
key={card.id}
500+
data-testid={`kanban-card-${card.id}`}
501+
draggable
502+
onDragStart={(e) => handleDragStart(e, card, column.id)}
503+
onDragEnd={handleDragEnd}
321504
className={cn(
322505
'p-3 rounded-lg border transition-all duration-200',
323-
'hover:scale-[1.02] hover:shadow-md cursor-grab',
506+
'hover:scale-[1.02] hover:shadow-md cursor-grab active:cursor-grabbing',
324507
card.color,
508+
draggedCard?.id === card.id && 'opacity-50',
325509
)}
326510
style={{
327511
animation: `fadeInUp 0.4s ease-out ${idx * 0.1 + cardIdx * 0.05 + 0.2}s forwards`,

0 commit comments

Comments
 (0)