# KanbanBoard

**When to use:** Drag-and-drop board with columns — task management, workflow stages, status boards (e.g. Todo / In Progress / Done).

**Keywords:** task board, trello-style board

**Import:** `import { KanbanBoard, KanbanColumn } from '@ahrowe/ui'`  
**Types:** `import type { KanbanBoardProps, KanbanColumnDef } from '@ahrowe/ui'`

```tsx
import { KanbanBoard } from '@ahrowe/ui';
import type { KanbanColumnDef } from '@ahrowe/ui';

type Task = { title: string; priority: 'low' | 'high' };

const [columns, setColumns] = useState<KanbanColumnDef[]>([
  { id: 'todo',       title: 'To Do',       itemIds: ['task-1', 'task-2'] },
  { id: 'inprogress', title: 'In Progress',  itemIds: ['task-3'] },
  { id: 'done',       title: 'Done',         itemIds: [] },
]);

const items: Record<string, Task> = {
  'task-1': { title: 'Design mockup', priority: 'high' },
  'task-2': { title: 'Write tests',   priority: 'low' },
  'task-3': { title: 'Fix bug #42',   priority: 'high' },
};

<KanbanBoard
  columns={columns}
  items={items}
  renderItem={(task, id) => (
    <div className="task-card">
      <strong>{task.title}</strong>
      <span>{task.priority}</span>
    </div>
  )}
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>

// Drag by a handle instead of the whole card: needed when a card contains its own
// controls (buttons, inputs). The handle is also the card's keyboard handle, named
// with getItemLabel, so the card is not one button wrapped around the Delete button
<KanbanBoard
  columns={columns}
  items={items}
  dragHandle
  getItemLabel={(task) => task.title}
  renderItem={(task, id) => (
    <div className="task-card">
      <span data-drag-handle>⠿</span>
      <strong>{task.title}</strong>
      <button onClick={() => deleteTask(id)}>Delete</button>
    </div>
  )}
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>

// Work-in-progress limit — shows a "count/max" badge on the column header and a
// warning border once it's exceeded. Display-only: KanbanBoard doesn't block the
// drop itself, so enforce a hard cap (if you want one) inside onChange, by simply
// not calling setColumns when the destination column would go over its maxItems.
const columnsWithLimit: KanbanColumnDef[] = [
  { id: 'inprogress', title: 'In Progress', itemIds: ['task-3'], maxItems: 3 },
];

// Accessible labels for screen-reader drag announcements — defaults to the card's
// id when omitted, so this is purely an upgrade for clearer narration
<KanbanBoard
  columns={columns}
  items={items}
  getItemLabel={(task) => task.title}
  renderItem={(task, id) => (
    <div className="task-card">
      <strong>{task.title}</strong>
    </div>
  )}
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>

// Freeze a column — cards inside can't be picked up, and nothing can be dropped
// into it. Use for a workflow rule like "cards in Done can't be moved back out."
const columnsWithLock: KanbanColumnDef[] = [
  { id: 'done', title: 'Done', itemIds: ['task-3'], disabled: true },
];

// Pin an individual card so it can't be dragged, independent of its column —
// e.g. a task that's locked pending approval
<KanbanBoard
  columns={columns}
  items={items}
  getItemDisabled={(task) => task.locked === true}
  renderItem={(task, id) => (
    <div className="task-card">
      <strong>{task.title}</strong>
      {task.locked && <span>🔒</span>}
    </div>
  )}
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>

// Custom content for an empty column, instead of leaving it blank
<KanbanBoard
  columns={columns}
  items={items}
  renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
  renderEmptyColumn={(column) => <span>No tasks in {column.title}</span>}
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>

// Custom actions in a column's header — an "add card" button, a column menu — laid
// out next to the title (and the maxItems count badge, when both are present)
<KanbanBoard
  columns={columns}
  items={items}
  renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
  renderColumnActions={(column) => (
    <button onClick={() => addTaskTo(column.id)} aria-label={`Add task to ${column.title}`}>+</button>
  )}
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>

// Let whole columns be reordered by dragging their header, alongside card dragging.
// The same onChange receives the reordered columns array — no separate callback needed.
<KanbanBoard
  columns={columns}
  items={items}
  renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
  reorderableColumns
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>

// Shrink a column with no cards to a narrow strip. Widths hold still during a drag,
// so a column the drag empties or fills changes width once the card is dropped
<KanbanBoard
  columns={columns}
  items={items}
  renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
  collapseEmptyColumns
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>

// Fill the height your layout gives the board, like a full-screen board app: give the
// board a height and each column scrolls on its own inside it. Here the board takes
// what the header leaves of the window (minHeight: 0 lets a flex child shrink below
// its content)
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
  <AppHeader />
  <KanbanBoard
    columns={columns}
    items={items}
    renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
    style={{ flex: 1, minHeight: 0 }}
    onChange={(updatedColumns) => setColumns(updatedColumns)}
  />
</div>

// Or cap each column at a length, so a long column scrolls on its own instead of
// growing the page, while a board of short columns stays short
<KanbanBoard
  columns={columns}
  items={items}
  renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
  columnMaxHeight="70vh"
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>

// Paginate a column instead of loading everything up front: mark it `hasMore`
// and fetch/append the next page in `onLoadMore` once it's scrolled near the
// bottom. Needs a column that scrolls: `columnMaxHeight` or a board with a height.
// KanbanBoard tracks its own in-flight state per column (shown as a spinner at
// the bottom of the list) and asks again while a column's cards leave empty space
// below them, so pagination doesn't dead-end on a short first batch.
const columnsPaged: KanbanColumnDef[] = [
  { id: 'done', title: 'Done', itemIds: doneTaskIds, hasMore: doneHasMore },
];

<KanbanBoard
  columns={columnsPaged}
  items={items}
  renderItem={(task) => <div className="task-card"><strong>{task.title}</strong></div>}
  columnMaxHeight="70vh"
  onLoadMore={async (columnId) => {
    const nextPage = await fetchNextPage(columnId);
    appendItemsToColumn(columnId, nextPage); // your own state update
  }}
  onChange={(updatedColumns) => setColumns(updatedColumns)}
/>
```

**Keyboard support:** cards are focusable, and the arrow keys move focus between them while nothing is picked up: Up/Down within a column, Left/Right to the nearest card in the next column. Space or Enter picks a card up, Space or Enter again drops it, and Escape cancels (reverts to the original position). While a card is picked up, the same arrow keys move the card instead: Up/Down reorders it within its column; Left/Right moves it into the adjacent column, landing on the nearest card there (or the column itself, if it's empty). This uses a board-aware coordinate getter rather than `dnd-kit`'s generic `sortableKeyboardCoordinates`, which, across multiple per-column `SortableContext`s, can't reliably tell "next card in this column" from "some other column's droppable" and ends up mixing up Up/Down and Left/Right. With `reorderableColumns`, each column's title is focusable too and joins the arrow-key navigation (Left/Right goes title to title, Up/Down between the title and the column's cards): Space/Enter picks the column up and Left/Right moves it. Keys pressed in a control inside a card or header, such as a `renderColumnActions` button or an input in a card, go to that control and never start a drag.

**Column reordering (`reorderableColumns`):** a column's header becomes its drag handle for moving the whole column — dragging the cards area still only moves cards. This shares the same `DndContext` as card dragging (mouse, touch, and keyboard all work the same way), and commits through the same `onChange` — a column move is just a reordering of the `columns` array, same as any other change.

**Scrolling while dragging:** holding a card near the edge of a column's card list scrolls that list. The page scrolls only toward a part of the board that is out of view, and stops once the board's edge is on screen, since nothing outside the board takes a drop.

**Screen-reader announcements:** every pick-up, hover, drop, and cancel is announced (e.g. "Picked up card Design mockup.", "Card Design mockup is over the In Progress column."). By default cards are announced by their id — pass `getItemLabel` to announce something meaningful instead (a title, a short description).

**KanbanBoardProps:**

| Prop | Type | Description |
|------|------|-------------|
| `columns` | `KanbanColumnDef[]` | Column definitions with `id`, `title`, `itemIds`, optional `maxItems` |
| `items` | `Record<string, T>` | Flat map of all items keyed by id |
| `renderItem` | `(item: T, id: string) => ReactNode` | Render function for each item |
| `onChange` | `(columns: KanbanColumnDef[]) => void` | Called after a drop that actually changes column or order — replace state with this value |
| `dragHandle` | `boolean` | Drag a card only by the element marked `data-drag-handle` inside it (mouse, touch and keyboard), instead of by the whole card. The board makes the handle focusable and names it with `getItemLabel` unless it has an `aria-label`, and the card stops being a button. Use when `renderItem` includes controls: without it the whole card is one button, its text can't be selected, and a screen reader reads controls inside it as plain text |
| `getItemLabel` | `(item: T, id: string) => string` | Accessible label for a card, used in screen-reader drag announcements and as the name of a `dragHandle` handle without one. Defaults to the card's id |
| `getItemDisabled` | `(item: T, id: string) => boolean` | Returns whether a specific card can't be picked up. Defaults to every card being draggable; independent of a column's own `disabled`. A pinned card stays a valid drop target — other cards can still be reordered before/after it, unlike a card inside a fully frozen (`disabled`) column |
| `renderEmptyColumn` | `(column: KanbanColumnDef) => ReactNode` | Custom content shown inside a column when it has no cards, instead of leaving it blank |
| `renderColumnActions` | `(column: KanbanColumnDef) => ReactNode` | Custom actions rendered in a column's header, next to its title (e.g. an "add card" button, a column menu) |
| `reorderableColumns` | `boolean` | Allow whole columns to be reordered by dragging their header, alongside card dragging (default `false`) |
| `collapseEmptyColumns` | `boolean` | Shrink a column to a narrow strip while it has no cards (default `false`). Widths hold still during a drag: a column the drag empties or fills changes width after the drop. The collapsed width is set via `--kanban-empty-column-width` (falls back to `140px`); override it theme-wide via `ThemeProvider`, or per board via `style` |
| `columnMaxHeight` | `string` | Caps each column's card list at a length (any CSS length, e.g. `'70vh'`), so a long column scrolls on its own instead of growing the page. Unset by default. To fill the space your layout gives the board instead, give the board a height (`style={{ height: '100%' }}` in a parent with one, or `flex: 1; min-height: 0` in a flex column): its columns then scroll the same way |
| `onLoadMore` | `(columnId: string) => void \| Promise<void>` | Called when a column with `hasMore` is scrolled within `loadMoreThreshold` of the bottom: fetch and append the next page for that column. Tracked per column, so overlapping calls for the same column are never made concurrently. Also called while the column's cards leave empty space below them. Paging all the way needs a column that scrolls (`columnMaxHeight` or a board with a height); on a board that grows with its cards, a short column only fills up to the tallest one |
| `loadMoreThreshold` | `number` | Distance in px from the bottom of a column's scroll area that triggers `onLoadMore` (default `100`, matching `VirtualList`) |
| `id` / `role` / `aria-*` / `data-*` | | Passed through to the root element |

`KanbanColumn`'s own props accept the same `id` / `role` / `aria-*` / `data-*` pass-through on its root element.

**KanbanColumnDef:**

```ts
{
  id: string;
  title: string;
  itemIds: string[];
  maxItems?: number; // work-in-progress limit — display-only, see above
  disabled?: boolean; // freezes the column — cards inside can't be dragged, nothing can drop in
  hasMore?: boolean; // more items exist beyond itemIds — enables onLoadMore, see above
}
```

**Note on item ids:** don't give a card an id starting with `column-` — the board uses that prefix internally to identify a column's own droppable (the empty space above/below its cards) versus a specific card. A colliding card id is silently misread as a column reference, and drops involving it silently no-op instead of throwing, so the bug can be easy to miss.

**Slots (KanbanBoard):** `dragOverlay`  
**Slots (KanbanColumn):** `header` `items` `count` (the `count/maxItems` badge, only rendered when `maxItems` is set) `placeholder` (the `renderEmptyColumn` wrapper, only rendered when the column has no cards and `renderEmptyColumn` is set) `actions` (the `renderColumnActions` wrapper, only rendered when `renderColumnActions` is set) `loadingIndicator` (the spinner shown while an `onLoadMore` request for that column is in flight)
