# Kanban

## Overview

`Kanban` renders a board of columns holding draggable cards, with work-in-progress limits as a first-class concept. It is controlled: the board reports a move and the consumer applies it, so card ordering lives with the data rather than inside a widget.

---

## Import

```tsx
import {
  Kanban,
  KanbanSkeleton,
  applyKanbanMove,
  type KanbanColumn,
  type KanbanMove,
} from 'xertica-ui/ui';
```

---

## Why not HTML5 drag-and-drop

The native drag-and-drop API cannot be driven from a keyboard and behaves poorly under touch. A board built on it needs a second, separate mechanism for anyone not using a mouse — which is how "accessible Kanban" usually ends up meaning a worse Kanban.

This component uses pointer events instead, so **one state machine and one commit path serve both input modes**. Focus a card, press Space to grab it, move with the arrow keys, Space to drop, Escape to cancel. Every step is announced through a live region. Avoiding a drag-and-drop dependency follows from that choice rather than motivating it.

---

## Props

| Prop                 | Type                               | Default      | Description                                                     |
| -------------------- | ---------------------------------- | ------------ | --------------------------------------------------------------- |
| `columns`            | `KanbanColumn[]`                   | required     | The board.                                                      |
| `onMove`             | `(move: KanbanMove) => void`       | -            | Fires with the requested move. Apply it with `applyKanbanMove`. |
| `onCardActivate`     | `(cardId, card) => void`           | -            | Card opened — click without a drag, or Enter on a resting card. |
| `selectedCardId`     | `string \| null`                   | -            | Highlights a card, for pairing with a detail panel.             |
| `onSelect`           | `(cardId: string \| null) => void` | -            | Selection changed.                                              |
| `renderCard`         | `(card, column) => ReactNode`      | -            | Replaces the card body. **Content only** — never behaviour.     |
| `renderColumnHeader` | `(column) => ReactNode`            | -            | Replaces the column header.                                     |
| `wipLimitBehavior`   | `'warn' \| 'block'`                | `'warn'`     | What a drop past a limit does.                                  |
| `columnWidth`        | `string`                           | `'20rem'`    | Column width.                                                   |
| `height`             | `string`                           | `'32rem'`    | Board height.                                                   |
| `readOnly`           | `boolean`                          | `false`      | Board stays legible, nothing can be moved.                      |
| `ariaLabel`          | `string`                           | `"Board"`    | Accessible name for the board.                                  |
| `emptyColumnLabel`   | `string`                           | `"No cards"` | Shown in an empty column.                                       |
| `labels`             | `Partial<KanbanLabels>`            | English      | Announcement and control strings.                               |

### `KanbanColumn`

| Field         | Type           | Description                                       |
| ------------- | -------------- | ------------------------------------------------- |
| `id`          | `string`       | Stable.                                           |
| `title`       | `string`       | Header text.                                      |
| `cards`       | `KanbanCard[]` | In display order.                                 |
| `limit`       | `number`       | Work-in-progress limit. Shown as `count / limit`. |
| `colorToken`  | `ColorToken`   | Header accent.                                    |
| `locked`      | `boolean`      | Nothing may be dropped here.                      |
| `description` | `string`       | Subtitle under the header.                        |

### `KanbanCard`

| Field         | Type                            | Description                                    |
| ------------- | ------------------------------- | ---------------------------------------------- |
| `id`          | `string`                        | Stable.                                        |
| `title`       | `string`                        | Also the card's accessible name.               |
| `description` | `string`                        | Clamped to three lines.                        |
| `badges`      | `Array<{ label, colorToken? }>` | Priority, tag, type.                           |
| `assignee`    | `{ name, avatarUrl? }`          | Avatar plus name.                              |
| `meta`        | `string`                        | Right-aligned secondary line — a due date.     |
| `locked`      | `boolean`                       | Pinned in place, still readable and focusable. |

---

## Model utilities

Exported and pure, so the tricky part is testable without a DOM:

- `applyKanbanMove(columns, move)` — returns new columns. **`move.to.index` is a slot in the destination with the card already lifted out**, which is what both drag paths produce.
- `checkKanbanMove(columns, move, behavior)` — `{ allowed: true }` or a reason: `'locked-card'`, `'locked-column'`, `'wip-limit'`.
- `locateKanbanCard(columns, cardId)`, `isKanbanColumnAtLimit(column)`, `isKanbanColumnOverLimit(column)`.

---

## Keyboard

| Key                    | Action                                  |
| ---------------------- | --------------------------------------- |
| `Tab`                  | Moves between cards.                    |
| `Space` (card at rest) | Grabs the card.                         |
| `←` `→`                | While held: moves between columns.      |
| `↑` `↓`                | While held: reorders within the column. |
| `Space` (while held)   | Drops the card.                         |
| `Escape`               | Cancels and returns the card.           |
| `Enter`                | Opens the card.                         |

---

## Example

```tsx
const [columns, setColumns] = useState<KanbanColumn[]>(initial);

<Kanban
  columns={columns}
  height="34rem"
  ariaLabel="Fluxo processual"
  wipLimitBehavior="block"
  onMove={move => setColumns(current => applyKanbanMove(current, move))}
  onCardActivate={id => openDrawer(id)}
/>;
```

---

## Loading States

```tsx
{isLoading ? <KanbanSkeleton columns={4} cards={3} height="34rem" /> : <Kanban … />}
```

Match `columns` and `cards` to the loaded board so the layout does not shift.

---

## AI Rules

- **Always** apply the move yourself in `onMove`. The board is controlled; without it, dragging appears broken.
- **Always** set `limit` on columns that have one — capping work in progress is the purpose of a Kanban, not a decoration.
- **Always** keep card and column ids stable. Drag, selection and announcements key off them.
- **Always** pair a loading board with `KanbanSkeleton`, matching the column and card counts.
- **Never** add drag handlers inside `renderCard`. It replaces content, not behaviour.
- **Never** reach for a drag-and-drop library to extend this. The pointer/keyboard state machine is shared; a second implementation would break the keyboard path.
- Common mistake: treating `move.to.index` as a position in the untouched column. It is a slot with the card already lifted out — pass the move straight to `applyKanbanMove` rather than adjusting it.
