Kanban-style drag-and-drop board built on Pragmatic drag and drop, managing multi-column ticket reordering with cross-column movement, collapse state, and a custom horizontal scrollbar. The board holds no copy of `columns` and changes no state during a drag: a single `monitorForElements` turns the drop into one `onChange`. ## Key Components | Export | Description | |---|---| | `Board` | Main board component rendering the columns it is given | | `BoardProps` | Interface defining all board configuration and callback props | ## Key Behaviors - **One monitor, one change** — A single `monitorForElements` watches the whole board; `resolveBoardDrop` (own module, unit-tested) turns the drop into a `BoardChange` or `null` - **Which target** — Two nested drop targets answer in the same terms, innermost first: a card, then its lane (for the gaps between cards, resolved to the nearest card and the edge of it the pointer is on). **Outside the lanes there is deliberately no target at all and releasing cancels** — Jira's model; landing a card "somewhere" because the pointer happened to be nearest to it makes a mis-drop unrecoverable. The aim arithmetic itself lives in `aim.ts` (slot indices, so one landing place can never be described two ways), shared verbatim by the pointer, the keyboard and the drop - **Nothing moves during a drag** — The dragged card stays put and fades; the landing place is a line. No margins open, no slots close, so the lane's height cannot wander and an edge flip costs one span instead of a relayout plus a card render - **One aim, one preview** — Where the drop points is resolved once, in the board's monitor, and handed down (`drop-aim.ts`). Each card used to work it out from its own hover events, which only ever answers for a card the pointer is physically over — so the preview vanished the moment the pointer left the lanes, even though the drop would still have landed. The board's answer is compared before it is stored, so a pointer moving inside one card does not re-render anything - **Cross-column guards** — `dropDisabled` and `allowedFromColumns` are enforced by each target's `canDrop`, so an invalid drop is never accepted and nothing has to be reverted - **Optimistic view** — The board renders `applyPendingMove(columns, pendingMove)` from the drop until the host's answer arrives (`pending-move.ts`), so the move is on screen instantly and the host's data replaces an identical picture. "Landed" is judged per kind of move: across lanes, arriving in the new lane once the old one has let go (a server may re-sort the lane, so the exact index would never come); within one lane, the exact slot. Weaker tests (a fresh `columns` array, the card leaving its old index) fire on a lane's own poll and snap the card back mid-flight. A 2s timeout is the backstop for a host that never applies the change - **Dragged card** — The card under the pointer is the browser's own native drag image (a styled DOM clone via `setCustomNativeDragPreview`), so the browser moves it — over its own chrome included — and carrying a card re-renders nothing and cannot freeze at the viewport edge. On drop, a portal copy takes over for one 160ms FLIP glide from the release point into the slot the move just opened, then hands over to the real card (hidden under it via `LandingCardContext` for exactly that long) - **Keyboard move** — Space on a focused card lifts it; the arrows walk it (up/down through slots, left/right through lanes, skipping any that will not take it); space or enter drops it; escape puts it back. Lift-and-place rather than one-press-one-move, because on a board every press would otherwise be its own write to the server. The lifted card feeds the same aim a pointer drag does, and the drop line is scrolled into view on every step — arrow keys bring no auto-scroll of their own. Keys are handled on the board, not the card: once a card is lifted they belong to the move rather than to whatever holds focus. Rules live in `aim.ts`, pure and unit-tested - **Collapse persistence** — Column collapsed state stored via `useBoardCollapse` (keyed by `collapseStorageKey`) - **Custom scrollbar** — Horizontal scrollbar rendered via `useHorizontalScrollbar` with pointer-drag thumb support - **Auto-scroll** — The board registers its own scroller and every scrollable ancestor once (`autoScrollAncestors`); each lane registers itself with a vertical-only overflow reach (`autoScrollColumn`), so a pointer below the board scrolls exactly the one lane it is under. `preventUnhandled` + `holdMoveDragEffect` keep the cursor a plain move cursor for the whole drag — without them the first frame and everything outside the lanes draw the browser's green copy cursor - **`onChange` payload** — Emits `BoardChange` with `ticketId`, `fromColumnId`, `toColumnId`, `afterTicketId`, and `beforeTicketId` for precise ordering ## Usage Example ```typescript import { Board } from './board' import type { BoardColumnDef, BoardChange } from './types' const columns: BoardColumnDef[] = [ { id: 'todo', label: 'To Do', color: '#6366f1', tickets: [...] }, { id: 'in-progress', label: 'In Progress', color: '#f59e0b', tickets: [...] }, { id: 'done', label: 'Done', color: '#10b981', tickets: [...], dropDisabled: false }, ] function handleChange(change: BoardChange) { console.log(`Moved ${change.ticketId} from ${change.fromColumnId} to ${change.toColumnId}`) // Persist reorder to backend } fetchMoreTickets(columnId)} onAddTicket={(columnId) => openCreateModal(columnId)} getTicketHref={(id) => `/tickets/${id}`} collapseStorageKey="board-collapse-state" /> ``` ## Source [`board.tsx`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/board.tsx)