import type { KanbanColumn, KanbanMove, KanbanPosition, WipLimitBehavior } from './kanban.types'; /** Where a card currently sits, or `null` if it is not on the board. */ export function locateCard(columns: KanbanColumn[], cardId: string): KanbanPosition | null { for (const column of columns) { const index = column.cards.findIndex(card => card.id === cardId); if (index !== -1) return { columnId: column.id, index }; } return null; } export function columnById(columns: KanbanColumn[], columnId: string): KanbanColumn | undefined { return columns.find(column => column.id === columnId); } /** * Whether a move is allowed, and why not when it is refused. * * @description * Returning the reason rather than a bare boolean is what lets the board explain * itself: a drop that silently does nothing reads as a broken component, and the * announcement a screen-reader user hears has to say something more useful than * "no". */ export function checkMove( columns: KanbanColumn[], move: KanbanMove, behavior: WipLimitBehavior = 'warn' ): { allowed: true } | { allowed: false; reason: 'locked-card' | 'locked-column' | 'wip-limit' } { const source = columnById(columns, move.from.columnId); const target = columnById(columns, move.to.columnId); if (!source || !target) return { allowed: false, reason: 'locked-column' }; const card = source.cards[move.from.index]; if (card?.locked) return { allowed: false, reason: 'locked-card' }; if (target.locked) return { allowed: false, reason: 'locked-column' }; // Reordering inside a column cannot breach its own limit. if (behavior === 'block' && move.from.columnId !== move.to.columnId) { if (typeof target.limit === 'number' && target.cards.length >= target.limit) { return { allowed: false, reason: 'wip-limit' }; } } return { allowed: true }; } /** * Applies a move, returning new columns. * * @description * Pure, so the board stays controlled and the consumer owns persistence — and so * the trickiest part of a Kanban can be unit-tested without a DOM. * * **`to.index` is a slot in the destination column with the card already lifted * out**, not a position in the untouched list. Both drag paths compute it that * way — the pointer hit-test and the keyboard stepper each exclude the card in * flight — so the model matches them instead of quietly re-deriving it. Holding * two coordinate systems is how a card ends up one slot from where it was * released. */ export function applyMove(columns: KanbanColumn[], move: KanbanMove): KanbanColumn[] { const source = columnById(columns, move.from.columnId); if (!source) return columns; const card = source.cards[move.from.index]; if (!card || card.id !== move.cardId) return columns; return columns.map(column => { if (column.id === move.from.columnId && column.id === move.to.columnId) { const cards = column.cards.slice(); cards.splice(move.from.index, 1); cards.splice(Math.max(0, Math.min(cards.length, move.to.index)), 0, card); return { ...column, cards }; } if (column.id === move.from.columnId) { const cards = column.cards.slice(); cards.splice(move.from.index, 1); return { ...column, cards }; } if (column.id === move.to.columnId) { const cards = column.cards.slice(); cards.splice(Math.max(0, Math.min(cards.length, move.to.index)), 0, card); return { ...column, cards }; } return column; }); } /** True when a column is at or past its WIP limit. */ export function isOverLimit(column: KanbanColumn): boolean { return typeof column.limit === 'number' && column.cards.length > column.limit; } export function isAtLimit(column: KanbanColumn): boolean { return typeof column.limit === 'number' && column.cards.length >= column.limit; } /** * Next position when the keyboard moves a held card. * * Left and right cross columns, up and down reorder. Crossing into a column * clamps to its length rather than refusing, so a card can always land * somewhere — a keyboard drag that silently does nothing is a dead end. */ export function stepPosition( columns: KanbanColumn[], current: KanbanPosition, direction: 'left' | 'right' | 'up' | 'down', /** The card being held is out of its column while in flight. */ heldFrom: KanbanPosition ): KanbanPosition { const columnIndex = columns.findIndex(column => column.id === current.columnId); if (columnIndex === -1) return current; /** Slots available in a column, accounting for the held card's absence. */ const slotsIn = (index: number) => { const column = columns[index]; const occupied = column.id === heldFrom.columnId ? column.cards.length - 1 : column.cards.length; return Math.max(0, occupied); }; if (direction === 'up' || direction === 'down') { const delta = direction === 'up' ? -1 : 1; const max = slotsIn(columnIndex); return { ...current, index: Math.max(0, Math.min(max, current.index + delta)) }; } const delta = direction === 'left' ? -1 : 1; const nextColumn = columnIndex + delta; if (nextColumn < 0 || nextColumn >= columns.length) return current; return { columnId: columns[nextColumn].id, index: Math.max(0, Math.min(slotsIn(nextColumn), current.index)), }; }