Skip to main content

General

Kanban Board code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/kanban-board.blade.php

@props([
    'columns' => [],
])

@php
    /*
    | Kanban board with native drag-and-drop between columns (no dependencies).
    | Cards can be dragged across columns; the board model updates reactively.
    | Pass `columns` as:
    |   [
    |     ['key' => 'todo', 'title' => 'To do', 'accent' => 'gray', 'cards' => [
    |        ['title' => 'Draft spec', 'meta' => 'DZC-21', 'tag' => 'Design', 'tagVariant' => 'blue', 'assignee' => 'AM'],
    |     ]],
    |   ]
    |
    | Usage:
    |   <x-ui.kanban-board :columns="$columns" />
    */
    $columnData = collect($columns)->map(function (array $col, int $ci): array {
        return [
            'key' => $col['key'] ?? 'col'.$ci,
            'title' => $col['title'] ?? '',
            'accent' => $col['accent'] ?? 'gray',
            'cards' => collect($col['cards'] ?? [])->map(fn (array $card, int $i): array => [
                'id' => $card['id'] ?? ('c'.$ci.'_'.$i),
                'title' => $card['title'] ?? '',
                'meta' => $card['meta'] ?? null,
                'tag' => $card['tag'] ?? null,
                'tagVariant' => $card['tagVariant'] ?? 'gray',
                'assignee' => $card['assignee'] ?? null,
            ])->values()->all(),
        ];
    })->values()->all();

    $accentDot = [
        'brand' => 'bg-brand-500',
        'green' => 'bg-green-500',
        'amber' => 'bg-amber-500',
        'red' => 'bg-red-500',
        'blue' => 'bg-accent-500',
        'gray' => 'bg-gray-400',
    ];
@endphp

<div
    x-data="{
        board: @js($columnData),
        accentDot: @js($accentDot),
        dragId: null,
        dragOver: null,
        tagClass(variant) {
            return ({
                brand: 'bg-brand-50 text-brand-700 dark:bg-brand-950/60 dark:text-brand-300',
                green: 'bg-green-50 text-green-700 dark:bg-green-950/60 dark:text-green-300',
                amber: 'bg-amber-50 text-amber-700 dark:bg-amber-950/60 dark:text-amber-300',
                red: 'bg-red-50 text-red-700 dark:bg-red-950/60 dark:text-red-300',
                blue: 'bg-accent-50 text-accent-700 dark:bg-accent-950/60 dark:text-accent-300',
                gray: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300',
            })[variant] || 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300';
        },
        drop(targetKey) {
            if (! this.dragId) { return; }
            let card = null;
            this.board.forEach((col) => {
                const idx = col.cards.findIndex((c) => c.id === this.dragId);
                if (idx !== -1) { card = col.cards.splice(idx, 1)[0]; }
            });
            if (card) {
                const target = this.board.find((col) => col.key === targetKey);
                if (target) { target.cards.push(card); }
            }
            this.dragId = null;
            this.dragOver = null;
        },
    }"
    {{ $attributes->merge(['class' => 'flex gap-4 overflow-x-auto pb-2']) }}
>
    <template x-for="column in board" :key="column.key">
        <div
            class="flex w-72 shrink-0 flex-col rounded-2xl border border-gray-200 bg-gray-50/70 dark:border-gray-800 dark:bg-gray-900/50"
            :class="dragOver === column.key ? 'ring-2 ring-brand-400/60' : ''"
            @dragover.prevent="dragOver = column.key"
            @dragleave="dragOver === column.key && (dragOver = null)"
            @drop="drop(column.key)"
        >
            <div class="flex items-center justify-between gap-2 px-4 py-3">
                <div class="flex items-center gap-2">
                    <span class="h-2.5 w-2.5 rounded-full" :class="accentDot[column.accent] || 'bg-gray-400'"></span>
                    <span class="text-sm font-semibold text-gray-900 dark:text-white" x-text="column.title"></span>
                </div>
                <span class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-gray-200 px-1.5 text-[0.6875rem] font-bold tabular-nums text-gray-600 dark:bg-gray-800 dark:text-gray-300" x-text="column.cards.length"></span>
            </div>

            <div class="flex min-h-24 flex-1 flex-col gap-2 px-3 pb-3">
                <template x-for="card in column.cards" :key="card.id">
                    <div
                        draggable="true"
                        @dragstart="dragId = card.id"
                        @dragend="dragId = null"
                        class="cursor-grab rounded-xl border border-gray-200 bg-white p-3 shadow-sm transition active:cursor-grabbing hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
                        :class="dragId === card.id ? 'opacity-50' : ''"
                    >
                        <div class="flex items-start justify-between gap-2">
                            <p class="text-sm font-medium text-gray-900 dark:text-white" x-text="card.title"></p>
                            <span
                                x-show="card.tag"
                                x-cloak
                                class="shrink-0 rounded-full px-2 py-0.5 text-[0.6875rem] font-semibold"
                                :class="tagClass(card.tagVariant)"
                                x-text="card.tag"
                            ></span>
                        </div>
                        <div class="mt-3 flex items-center justify-between gap-2">
                            <span x-show="card.meta" x-cloak class="font-mono text-[0.6875rem] uppercase tracking-wide text-gray-400 dark:text-gray-500" x-text="card.meta"></span>
                            <span
                                x-show="card.assignee"
                                x-cloak
                                class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-brand-100 text-[0.625rem] font-bold text-brand-700 dark:bg-brand-950/60 dark:text-brand-300"
                                x-text="card.assignee"
                            ></span>
                        </div>
                    </div>
                </template>

                <p x-show="column.cards.length === 0" x-cloak class="rounded-xl border border-dashed border-gray-200 py-6 text-center text-xs text-gray-400 dark:border-gray-800 dark:text-gray-500">
                    Drop cards here
                </p>
            </div>
        </div>
    </template>
</div>