Widgets
Kanban
A board with draggable cards and columns, and WIP limits.
A board with draggable cards and columns, WIP limits, hidden columns and optimistic state. It knows nothing about your domain: what to show on a card is decided by renderCard.
Installation
npx @toimetdev/pathlogs-ui add kanbanCopies three files: Kanban.tsx, ColumnEditor.tsx and kanbanOrder.ts. From then on it is your code — edit it like any other file in the project.
Example
К выполнению
Вынести разбор Markdown в отдельный модуль
WIP-лимиты у колонок
В работе
Импорт досок из Trello падает на больших проектах
Живые обновления доски по SSE
На проверке
Критический путь на диаграмме Ганта
Готово
Тултипы обрезались в колонках доски
<Kanban
items={tasks}
columns={columns}
canManageColumns={isManager}
renderCard={(task) => <TaskCard task={task} />}
onOpenItem={(task) => router.push(`/tasks/${task.id}`)}
onMoveItem={(id, columnId, orderedIds) => moveTaskAction(id, columnId, orderedIds)}
onReorderColumns={(ids) => reorderColumnsAction(projectId, ids)}
onUpdateColumn={(id, fields) => updateColumnAction(id, fields)}
onDeleteColumn={deleteColumnAction}
labels={EN_LABELS}
/>Try it: drag a card between columns and within a column, move a column by the handle on the left, open its settings, change the WIP limit. The «Done» column is sorted by date — the slot there appears where the card will actually land.
The data
The board needs a minimum of fields — everything else is yours:
interface KanbanItem {
id: string;
columnId: string | null;
order: number;
createdAt: string; // ISO — compared lexicographically
color?: string | null; // the card's personal colour
}
interface KanbanColumn {
id: string;
name: string;
color: string; // #rrggbb — tints the whole column
order: number;
wipLimit?: number | null;
sort?: "MANUAL" | "CREATED_DESC" | "CREATED_ASC";
hidden?: boolean;
}Your own type simply extends the base one:
interface Task extends KanbanItem {
number: number;
title: string;
priority: 1 | 2 | 3 | 4;
assignees: Member[];
}
<Kanban<Task, KanbanColumn> items={tasks} … />Props
| Prop | Type | Default | Description |
|---|---|---|---|
items* | I[] | — | The cards. |
columns* | C[] | — | The columns. |
renderCard* | (item, ctx) => ReactNode | — | The card's contents. ctx carries { dragging, column } — to dim the card while it is being moved, for instance. |
onMoveItem* | (itemId, columnId, orderedIds) => void | Promise | — | A card moved. orderedIds is the complete new order of the target column. |
onReorderColumns | (orderedIds: string[]) => void | Promise | — | Without it columns cannot be dragged — no handle is shown. |
onCreateColumn | (name, color) => void | Promise | — | Without it there is no «new column» button. |
onUpdateColumn | (columnId, fields) => void | Promise | — | Without it there is no column settings button. |
onSetColumnHidden | (columnId, hidden) => void | Promise | — | Hiding a column, plus the «hidden columns» bar at the bottom. |
onDeleteColumn | (columnId) => void | Promise | — | Deleting a column. |
onOpenItem | (item: I) => void | — | A click on a card. |
filter | (item: I) => boolean | — | A card filter. Columns stay in place — you can see both the board's structure and how much is left in it. |
canManageColumns | boolean | false | Permission to change the set of columns: creating and deleting. |
palette | readonly string[] | — | The palette of column colours. |
toolbar | ReactNode | — | The bar above the board: a filter, an update indicator. |
labels | KanbanLabels | — | Captions. English by default. |
items*I[]The cards.
columns*C[]The columns.
renderCard*(item, ctx) => ReactNodeThe card's contents. ctx carries { dragging, column } — to dim the card while it is being moved, for instance.
onMoveItem*(itemId, columnId, orderedIds) => void | PromiseA card moved. orderedIds is the complete new order of the target column.
onReorderColumns(orderedIds: string[]) => void | PromiseWithout it columns cannot be dragged — no handle is shown.
onCreateColumn(name, color) => void | PromiseWithout it there is no «new column» button.
onUpdateColumn(columnId, fields) => void | PromiseWithout it there is no column settings button.
onSetColumnHidden(columnId, hidden) => void | PromiseHiding a column, plus the «hidden columns» bar at the bottom.
onDeleteColumn(columnId) => void | PromiseDeleting a column.
onOpenItem(item: I) => voidA click on a card.
filter(item: I) => booleanA card filter. Columns stay in place — you can see both the board's structure and how much is left in it.
canManageColumnsbooleandefaults to: false
Permission to change the set of columns: creating and deleting.
palettereadonly string[]The palette of column colours.
toolbarReactNodeThe bar above the board: a filter, an update indicator.
labelsKanbanLabelsCaptions. English by default.
Optimistic state
The board applies a move immediately and holds its own state until the server answers. Fresh items replace it — but only once every action that started has finished.
onMoveItem receives the complete order of the column, not a single position: the server must write the whole order, otherwise two simultaneous moves diverge.
Drag-and-drop subtleties
Several decisions here, each an answer to a specific breakage:
- The source hides on the first
drag, not ondragstart.dragstartis a discrete event, so React would applysetStatesynchronously, the card would vanish at the very moment of the start, and the browser would cancel the drag. - The hidden card stays in the tree (the
hiddenattribute rather than removal). Otherwise itsonDragEndwould not fire when the drag is cancelled with Escape — and the board would be left without that card. - The slot matches the card's height, so neighbours do not «jump» at the moment of pick-up.
- The strip auto-scrolls near the edge during a drag: the pointer belongs to the browser, and without auto-scroll there would be no way to reach a column off-screen.
- The column's borders are four separate properties. React updates the shorthand
borderColorandborderTopColorindependently, and the top stripe «sticks» from the previous state.
The ordering, separately
All the ordering rules live in kanbanOrder.ts — no React, no DOM, with tests:
import {
columnItems, // a column's cards in display order
dropSlotIndex, // where a card will land under each sort mode
insertAt, // the new order of ids after an insertion
reorderColumns, // the new order of columns
isOverWipLimit,
applyOrder,
} from "@/components/ui/kanban/kanbanOrder";The least obvious of these is dropSlotIndex. Under manual ordering the slot appears under the cursor; under date sorting, where the card will actually end up (otherwise it would jump after release); and with an active filter, at the end — because «the place under the cursor» says nothing about the real order when not all cards are visible.