# KanbanBoard

> Controlled board of columns and draggable cards (dnd-kit sortable) with keyboard moves, localised announcements and RTL column flow.

Source: https://docs.nasaqui.com/components/kanban-board

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/kanban-board.json
```

Columns of cards the user drags between and within. It is controlled: you pass `columns` and `cards`, and get
`onMove(cardId, toColumn, toIndex)` when a card is dropped somewhere new. Built on `@dnd-kit/core` and `@dnd-kit/sortable`
with a drag overlay, pointer, touch and keyboard sensors and screen-reader announcements.

## When to use

- Work that moves through stages: tasks, deals, tickets, candidates.

## When not to use

- Reordering one flat list: use a sortable list (see `data-table` row ordering or `sidebar-layout`).
- Many attributes per row: use `data-table`.

## Import

```tsx
import { KanbanBoard, KanbanCard } from "@fadymondy/nasaq/web";
```

## Quick start

```tsx
import { KanbanBoard, type KanbanCardData } from "@fadymondy/nasaq/web";
import { useState } from "react";

const columns = [
  { id: "todo", title: "To do" },
  { id: "done", title: "Done" },
];

export function Board() {
  const [cards, setCards] = useState<KanbanCardData[]>([
    { id: "a", columnId: "todo", title: "Write the brief", labels: [{ label: "Docs", hue: "blue" }], assignee: { name: "Sara Ali" } },
    { id: "b", columnId: "todo", title: "Review copy" },
  ]);
  return (
    <KanbanBoard
      columns={columns}
      cards={cards}
      onMove={(id, toColumn, toIndex) =>
        setCards((prev) => {
          const card = prev.find((c) => c.id === id);
          if (!card) return prev;
          const rest = prev.filter((c) => c.id !== id);
          const inColumn = rest.filter((c) => c.columnId === toColumn);
          inColumn.splice(toIndex, 0, { ...card, columnId: toColumn });
          return [...rest.filter((c) => c.columnId !== toColumn), ...inColumn];
        })
      }
    />
  );
}
```

## Anatomy

```
KanbanBoard            data-slot="kanban-board"   (role="group")
  section              data-slot="kanban-column"  (data-over while a card is over it)
    header             data-slot="kanban-column-header"  title (h3) + count Badge
    ul                 data-slot="kanban-list"
      li               data-slot="kanban-item"    (drag handle, data-dragging)
        KanbanCard     data-slot="kanban-card"
      li               data-slot="kanban-empty"   (placeholder when the column has no cards)
```

## API

### KanbanBoard

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns` | `KanbanColumnData[]` | required | `{ id, title }`, in display order. |
| `cards` | `T extends KanbanCardData` | required | Flat list. Cards keep the array order inside their column. |
| `onMove` | `(cardId, toColumn, toIndex) => void` | required | Fired once on drop, only when the card ended somewhere new. `toIndex` is the index in the destination column after the card is removed from its old place. |
| `renderCard` | `(card, { overlay, dragging }) => ReactNode` | `KanbanCard` | The card slot. The board supplies the drag handle. |
| `emptyLabel` | `string` | "No cards" / "لا توجد بطاقات" | Placeholder of an empty column. |
| `label` | `string` | "Kanban board" / "لوحة كانبان" | Group name. |
| `announcements` | `{ pickedUp?, movedOver?, dropped?, cancelled? }` | localised | Functions `(card, column, position, total) => string` (`cancelled` takes the card). |
| `instructions` | `string` | localised | Read when a card gets focus. |
| `columnClassName` | `string` | `w-72` | Applied to each column. |

### Types

`KanbanColumnData = { id: string; title: string }`.
`KanbanCardData = { id: string; columnId: string; title: string; labels?: { label: string; hue?: TagHue }[]; assignee?: { name: string; src?: string } }`.
Column and card ids share one namespace and must be unique. Extend `KanbanCardData` with your own fields and read them in `renderCard`.

### KanbanCard

`{ card: KanbanCardData }` plus `div` props. Title, tag Badges for labels, and the assignee's Avatar.

## Examples

Custom card body:

```tsx
import { KanbanBoard } from "@fadymondy/nasaq/web";

export function Compact({ columns, cards, onMove }: Parameters<typeof KanbanBoard>[0]) {
  return (
    <KanbanBoard
      columns={columns}
      cards={cards}
      onMove={onMove}
      emptyLabel="لا توجد بطاقات"
      renderCard={(card, { overlay }) => (
        <div className={overlay ? "rounded-control border border-border bg-card px-3 py-2 shadow-lg" : "rounded-control border border-border bg-card px-3 py-2"}>
          {card.title}
        </div>
      )}
    />
  );
}
```

## Context menu

`cardActions={(card) => ContextMenuAction[]}` opens a menu on right-click, Shift+F10 or the Menu key on a focused card (move to a column, delete…). Dragging is unchanged. `contextMenu={false}` opts out.

## Accessibility

Each card is focusable with `aria-roledescription` ("draggable card" / "بطاقة قابلة للسحب") and instructions.

| Key | Action |
| --- | --- |
| Tab | Moves focus between cards. |
| Space / Enter | Lifts the focused card; drops it while lifted. |
| Arrow Up / Down | Moves the lifted card within its column. |
| Arrow Left / Right | Moves the lifted card to the neighbouring column on that side (physical direction, also in RTL). |
| Escape | Cancels the move. |

Pick up, move, drop and cancel are announced through a live region, with the column title and 1-based position ("Dropped X in
Done, position 2 of 3"). Numbers use the locale's numerals. Override any string through `announcements` and `instructions`; the count badge
has an `aria-label` ("3 cards").

## RTL & i18n

The columns are a flex row, so they start from the right in RTL and the drag overlay follows the pointer. Built-in strings exist in
English and Arabic by the Nasaq locale. Card and column titles are your data, so localise them yourself.

## Styling & tokens

Uses `bg-secondary`, `bg-card`, `border-border`, `border-nq-focus` (a column that a card hovers over), `text-muted-foreground`.
Target `data-over` on columns and `data-dragging` on items. Extend with `className` and `columnClassName`.

## Do / Don't

- Do apply `onMove` to your own state; the board never reorders your data.
- Do keep ids unique across cards and columns.
- Don't put interactive controls that need a click inside a default card without stopping pointer events: the card is the drag handle.

## Related

- [card](https://docs.nasaqui.com/components/card)
- [badge](https://docs.nasaqui.com/components/badge)
- [avatar](https://docs.nasaqui.com/components/avatar)

## Lab

https://docs.nasaqui.com/?path=/docs/components-projects-work-kanban-board--docs

## Code

### React

```tsx
import { KanbanBoard, type KanbanCardData } from "@fadymondy/nasaq/web";
import { useState } from "react";

const columns = [
  { id: "todo", title: "To do" },
  { id: "done", title: "Done" },
];

export function Board() {
  const [cards, setCards] = useState<KanbanCardData[]>([
    { id: "a", columnId: "todo", title: "Write the brief", labels: [{ label: "Docs", hue: "blue" }], assignee: { name: "Sara Ali" } },
    { id: "b", columnId: "todo", title: "Review copy" },
  ]);
  return (
    <KanbanBoard
      columns={columns}
      cards={cards}
      onMove={(id, toColumn, toIndex) =>
        setCards((prev) => {
          const card = prev.find((c) => c.id === id);
          if (!card) return prev;
          const rest = prev.filter((c) => c.id !== id);
          const inColumn = rest.filter((c) => c.columnId === toColumn);
          inColumn.splice(toIndex, 0, { ...card, columnId: toColumn });
          return [...rest.filter((c) => c.columnId !== toColumn), ...inColumn];
        })
      }
    />
  );
}
```

### shadcn

```tsx
import { KanbanBoard, type KanbanCardData } from "@/components/ui/kanban-board";
import { useState } from "react";

const columns = [
  { id: "todo", title: "To do" },
  { id: "done", title: "Done" },
];

export function Board() {
  const [cards, setCards] = useState<KanbanCardData[]>([
    { id: "a", columnId: "todo", title: "Write the brief", labels: [{ label: "Docs", hue: "blue" }], assignee: { name: "Sara Ali" } },
    { id: "b", columnId: "todo", title: "Review copy" },
  ]);
  return (
    <KanbanBoard
      columns={columns}
      cards={cards}
      onMove={(id, toColumn, toIndex) =>
        setCards((prev) => {
          const card = prev.find((c) => c.id === id);
          if (!card) return prev;
          const rest = prev.filter((c) => c.id !== id);
          const inColumn = rest.filter((c) => c.columnId === toColumn);
          inColumn.splice(toIndex, 0, { ...card, columnId: toColumn });
          return [...rest.filter((c) => c.columnId !== toColumn), ...inColumn];
        })
      }
    />
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { NqKanbanBoard, type KanbanCardData } from "@fadymondy/nasaq/vue";
import { ref } from "vue";

const columns = [
  { id: "todo", title: "To do" },
  { id: "done", title: "Done" },
];
const cards = ref<KanbanCardData[]>([
  { id: "a", columnId: "todo", title: "Write the brief", labels: [{ label: "Docs", hue: "blue" }], assignee: { name: "Sara Ali" } },
  { id: "b", columnId: "todo", title: "Review copy" },
]);

function move(id: string, toColumn: string, toIndex: number) {
  const card = cards.value.find((c) => c.id === id);
  if (!card) return;
  const rest = cards.value.filter((c) => c.id !== id);
  const inColumn = rest.filter((c) => c.columnId === toColumn);
  inColumn.splice(toIndex, 0, { ...card, columnId: toColumn });
  cards.value = [...rest.filter((c) => c.columnId !== toColumn), ...inColumn];
}
</script>

<template>
  <NqKanbanBoard :columns="columns" :cards="cards" :on-move="move" />
</template>
```

### Blade

```blade
<x-nq::kanban-board
    :columns="[['id' => 'todo', 'title' => 'To do'], ['id' => 'done', 'title' => 'Done']]"
    :cards="[
        ['id' => 'a', 'columnId' => 'todo', 'title' => 'Write the brief', 'labels' => [['label' => 'Docs', 'hue' => 'blue']], 'assignee' => ['name' => 'Sara Ali']],
        ['id' => 'b', 'columnId' => 'todo', 'title' => 'Review copy'],
    ]" />
```

### HTML + Alpine

```html
<div data-slot="kanban-board" role="group" aria-label="Kanban board"
    x-data="nqKanbanBoard(JSON.parse('[{\u0022id\u0022:\u0022todo\u0022,\u0022title\u0022:\u0022To do\u0022},{\u0022id\u0022:\u0022done\u0022,\u0022title\u0022:\u0022Done\u0022}]'), JSON.parse('[{\u0022id\u0022:\u0022a\u0022,\u0022columnId\u0022:\u0022todo\u0022,\u0022title\u0022:\u0022Write the brief\u0022,\u0022labels\u0022:[{\u0022label\u0022:\u0022Docs\u0022,\u0022hue\u0022:\u0022blue\u0022}],\u0022assignee\u0022:{\u0022name\u0022:\u0022Sara Ali\u0022}},{\u0022id\u0022:\u0022b\u0022,\u0022columnId\u0022:\u0022todo\u0022,\u0022title\u0022:\u0022Review copy\u0022}]'), {})" x-modelable="cards"
    class="flex w-full items-start gap-4 overflow-x-auto pb-2">
    <p id="nq-kanban-hint-0979cf" class="sr-only">To pick up a card, press Space or Enter. Use the arrow keys to move it within or between columns, Space or Enter to drop it, Escape to cancel.</p>
    <template x-for="column in columns" :key="column.id">
        <section data-slot="kanban-column" x-bind:data-column-id="column.id" x-bind:data-over="overColumn() === column.id ? '' : null"
            class="flex max-h-full w-72 shrink-0 flex-col gap-3 rounded-card border border-border bg-secondary p-3 transition-colors duration-150 ease-nq data-over:border-nq-focus ">
            <header data-slot="kanban-column-header" class="flex items-center justify-between gap-2">
                <h3 class="min-w-0 truncate text-label text-foreground" x-text="column.title"></h3>
                <span data-slot="badge" x-bind:aria-label="countLabel(column.id)" class="inline-flex h-5 shrink-0 items-center gap-1 whitespace-nowrap rounded-[4px] border px-1.5 text-caption font-medium [&_svg]:size-3 border-border text-muted-foreground">
                    <span class="tabular-nums" x-text="num(count(column.id))"></span>
                </span>
            </header>
            <ul data-slot="kanban-list" class="flex min-h-16 flex-1 flex-col gap-2 overflow-y-auto">
                <template x-for="card in list(column.id)" :key="card.id">
                    <li data-slot="kanban-item" x-bind:data-card-id="card.id" x-bind:data-dragging="activeId === card.id ? '' : null" role="button" tabindex="0"
                        aria-roledescription="draggable card" x-bind:aria-pressed="activeId === card.id ? 'true' : 'false'" aria-describedby="nq-kanban-hint-0979cf"
                        x-bind:class="activeId === card.id && 'opacity-40'" x-on:pointerdown="down($event, card.id)" x-on:keydown="key($event, card.id)"
                        class="cursor-grab touch-manipulation rounded-card outline-none active:cursor-grabbing focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus">
                        <div data-slot="kanban-card" class="flex flex-col gap-2 rounded-card border border-border bg-card p-3 text-card-foreground">
    <div x-show="card.labels && card.labels.length" x-cloak style="display: none" class="flex flex-wrap gap-1">
        <template x-for="l in (card.labels || [])" :key="l.label">
            <span data-slot="badge" x-bind:style="tagStyle(l.hue)" class="inline-flex h-5 shrink-0 items-center gap-1 whitespace-nowrap rounded-[4px] border px-1.5 text-caption font-medium [&_svg]:size-3 border-transparent" x-text="l.label"></span>
        </template>
    </div>
    <div class="text-label text-foreground" x-text="card.title"></div>
    <div x-show="card.assignee" x-cloak style="display: none" class="flex items-center justify-end">
        <span data-slot="avatar" x-bind:title="card.assignee && card.assignee.name" class="inline-flex shrink-0 select-none items-center justify-center overflow-hidden bg-secondary align-middle font-medium text-secondary-foreground size-5 text-[9px] rounded-full" x-text="card.assignee ? initials(card.assignee.name) : ''"></span>
    </div>
</div>
                    </li>
                </template>
                <li data-slot="kanban-empty" x-show="count(column.id) === 0" x-cloak style="display: none" class="flex flex-1 items-center justify-center rounded-card border border-dashed border-border p-4 text-body-sm text-muted-foreground">No cards</li>
            </ul>
        </section>
    </template>
    <div data-slot="kanban-overlay" aria-hidden="true" x-show="activeCard() && overlay" x-cloak style="display: none" x-bind:style="overlay ? 'left:' + overlay.left + 'px;top:' + overlay.top + 'px;width:' + overlay.width + 'px' : null" class="pointer-events-none fixed z-50 cursor-grabbing shadow-lg">
        <template x-if="activeCard()">
            <template x-for="card in [activeCard()]" :key="card.id">
                <div data-slot="kanban-card" class="flex flex-col gap-2 rounded-card border border-border bg-card p-3 text-card-foreground">
    <div x-show="card.labels && card.labels.length" x-cloak style="display: none" class="flex flex-wrap gap-1">
        <template x-for="l in (card.labels || [])" :key="l.label">
            <span data-slot="badge" x-bind:style="tagStyle(l.hue)" class="inline-flex h-5 shrink-0 items-center gap-1 whitespace-nowrap rounded-[4px] border px-1.5 text-caption font-medium [&_svg]:size-3 border-transparent" x-text="l.label"></span>
        </template>
    </div>
    <div class="text-label text-foreground" x-text="card.title"></div>
    <div x-show="card.assignee" x-cloak style="display: none" class="flex items-center justify-end">
        <span data-slot="avatar" x-bind:title="card.assignee && card.assignee.name" class="inline-flex shrink-0 select-none items-center justify-center overflow-hidden bg-secondary align-middle font-medium text-secondary-foreground size-5 text-[9px] rounded-full" x-text="card.assignee ? initials(card.assignee.name) : ''"></span>
    </div>
</div>
            </template>
        </template>
    </div>
    <div role="status" aria-live="assertive" class="sr-only" x-text="announcement"></div>
</div>
```
