The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| 5 | import { cn } from "../lib/utils"; |
| 6 | import { transitions } from "../lib/motion"; |
| 7 |
| 8 | /** |
| 9 | * A board with drag and drop between and within columns. |
| 10 | * |
| 11 | * Pointer: drag a card; a slot opens where it will land. Keyboard: focus |
| 12 | * a card, press Space to pick it up, arrows to move it (←/→ across |
| 13 | * columns, ↑/↓ within one), Space or Enter to drop, Escape to cancel. |
| 14 | * Moves are announced to screen readers. |
| 15 | * |
| 16 | * The board holds no item state: it calls `onMove` and the parent |
| 17 | * updates its items (with `useLiveCollection`, optimistically). |
| 18 | */ |
| 19 |
| 20 | export interface KanbanColumn { |
| 21 | id: string; |
| 22 | title: string; |
| 23 | /** Dot color in the column header. */ |
| 24 | tone?: "default" | "info" | "positive" | "attention" | "risk"; |
| 25 | } |
| 26 |
| 27 | export interface KanbanMove { |
| 28 | itemId: string; |
| 29 | fromColumnId: string; |
| 30 | toColumnId: string; |
| 31 | /** Index within the destination column once the card has moved. */ |
| 32 | toIndex: number; |
| 33 | } |
| 34 |
| 35 | const TONE_DOT = { |
| 36 | default: "bg-muted-foreground/40", |
| 37 | info: "bg-action", |
| 38 | positive: "bg-positive", |
| 39 | attention: "bg-attention", |
| 40 | risk: "bg-risk", |
| 41 | } as const; |
| 42 |
| 43 | interface Position { |
| 44 | columnId: string; |
| 45 | index: number; |
| 46 | } |
| 47 |
| 48 | export function KanbanBoard<T>({ |
| 49 | columns, |
| 50 | items, |
| 51 | getItemId, |
| 52 | getColumnId, |
| 53 | renderCard, |
| 54 | onMove, |
| 55 | onOpen, |
| 56 | columnFooter, |
| 57 | label = "Board", |
| 58 | className, |
| 59 | }: { |
| 60 | columns: KanbanColumn[]; |
| 61 | items: readonly T[]; |
| 62 | getItemId: (item: T) => string; |
| 63 | getColumnId: (item: T) => string; |
| 64 | /** |
| 65 | * The card's contents only. The board draws the card itself (surface, |
| 66 | * border, radius, padding, drag and focus states); don't wrap the content |
| 67 | * in another bordered box. |
| 68 | */ |
| 69 | renderCard: (item: T, state: { moving: boolean }) => React.ReactNode; |
| 70 | onMove: (move: KanbanMove) => void; |
| 71 | onOpen?: (item: T) => void; |
| 72 | columnFooter?: (column: KanbanColumn) => React.ReactNode; |
| 73 | label?: string; |
| 74 | className?: string; |
| 75 | }) { |
| 76 | const reduceMotion = Boolean(useReducedMotion()); |
| 77 | const columnRefs = React.useRef(new Map<string, HTMLElement>()); |
| 78 | const cardRefs = React.useRef(new Map<string, HTMLElement>()); |
| 79 | const [dragging, setDragging] = React.useState<{ id: string; target: Position | null } | null>(null); |
| 80 | const [lifted, setLifted] = React.useState<{ id: string; target: Position } | null>(null); |
| 81 | const [announcement, setAnnouncement] = React.useState(""); |
| 82 |
| 83 | const byColumn = React.useMemo(() => { |
| 84 | const map = new Map<string, T[]>(columns.map((column) => [column.id, []])); |
| 85 | for (const item of items) map.get(getColumnId(item))?.push(item); |
| 86 | return map; |
| 87 | }, [columns, items, getColumnId]); |
| 88 |
| 89 | const titleOf = (columnId: string) => columns.find((column) => column.id === columnId)?.title ?? columnId; |
| 90 | const othersIn = (columnId: string, movingId: string) => |
| 91 | (byColumn.get(columnId) ?? []).filter((item) => getItemId(item) !== movingId); |
| 92 | const originOf = (itemId: string): Position | null => { |
| 93 | for (const [columnId, cards] of byColumn) { |
| 94 | const index = cards.findIndex((item) => getItemId(item) === itemId); |
| 95 | if (index >= 0) return { columnId, index }; |
| 96 | } |
| 97 | return null; |
| 98 | }; |
| 99 | const isOrigin = (itemId: string, position: Position) => { |
| 100 | const origin = originOf(itemId); |
| 101 | return origin?.columnId === position.columnId && origin.index === position.index; |
| 102 | }; |
| 103 |
| 104 | const positionAt = (x: number, y: number, movingId: string): Position | null => { |
| 105 | for (const column of columns) { |
| 106 | const rect = columnRefs.current.get(column.id)?.getBoundingClientRect(); |
| 107 | if (!rect || x < rect.left || x > rect.right) continue; |
| 108 | const others = othersIn(column.id, movingId); |
| 109 | let index = others.length; |
| 110 | for (let position = 0; position < others.length; position += 1) { |
| 111 | const box = cardRefs.current.get(getItemId(others[position]!))?.getBoundingClientRect(); |
| 112 | if (box && y < box.top + box.height / 2) { |
| 113 | index = position; |
| 114 | break; |
| 115 | } |
| 116 | } |
| 117 | return { columnId: column.id, index }; |
| 118 | } |
| 119 | return null; |
| 120 | }; |
| 121 |
| 122 | const commit = (itemId: string, destination: Position) => { |
| 123 | const origin = originOf(itemId); |
| 124 | if (!origin || isOrigin(itemId, destination)) return; |
| 125 | onMove({ itemId, fromColumnId: origin.columnId, toColumnId: destination.columnId, toIndex: destination.index }); |
| 126 | setAnnouncement(`Moved to ${titleOf(destination.columnId)}, position ${destination.index + 1}.`); |
| 127 | }; |
| 128 |
| 129 | const nudge = (deltaColumn: number, deltaIndex: number) => { |
| 130 | if (!lifted) return; |
| 131 | const columnIndex = columns.findIndex((column) => column.id === lifted.target.columnId); |
| 132 | const column = columns[Math.max(0, Math.min(columns.length - 1, columnIndex + deltaColumn))]!; |
| 133 | const size = othersIn(column.id, lifted.id).length; |
| 134 | const index = Math.max(0, Math.min(size, lifted.target.index + deltaIndex)); |
| 135 | setLifted({ id: lifted.id, target: { columnId: column.id, index } }); |
| 136 | setAnnouncement(`${titleOf(column.id)}, position ${index + 1} of ${size + 1}.`); |
| 137 | }; |
| 138 |
| 139 | const handleKey = (item: T, event: React.KeyboardEvent) => { |
| 140 | const id = getItemId(item); |
| 141 | const isLifted = lifted?.id === id; |
| 142 | if (event.key === " " || (event.key === "Enter" && isLifted)) { |
| 143 | event.preventDefault(); |
| 144 | if (isLifted) { |
| 145 | commit(id, lifted.target); |
| 146 | setLifted(null); |
| 147 | } else { |
| 148 | const origin = originOf(id); |
| 149 | if (!origin) return; |
| 150 | setLifted({ id, target: origin }); |
| 151 | setAnnouncement( |
| 152 | `Picked up. ${titleOf(origin.columnId)}, position ${origin.index + 1}. Arrow keys move, Space drops, Escape cancels.`, |
| 153 | ); |
| 154 | } |
| 155 | return; |
| 156 | } |
| 157 | if (event.key === "Enter" && onOpen) { |
| 158 | event.preventDefault(); |
| 159 | onOpen(item); |
| 160 | return; |
| 161 | } |
| 162 | if (!isLifted) return; |
| 163 | const moves: Record<string, [number, number]> = { |
| 164 | ArrowLeft: [-1, 0], |
| 165 | ArrowRight: [1, 0], |
| 166 | ArrowUp: [0, -1], |
| 167 | ArrowDown: [0, 1], |
| 168 | }; |
| 169 | const move = moves[event.key]; |
| 170 | if (move) { |
| 171 | event.preventDefault(); |
| 172 | nudge(move[0], move[1]); |
| 173 | } else if (event.key === "Escape") { |
| 174 | event.preventDefault(); |
| 175 | setLifted(null); |
| 176 | setAnnouncement("Move cancelled."); |
| 177 | } |
| 178 | }; |
| 179 |
| 180 | const card = (item: T) => { |
| 181 | const id = getItemId(item); |
| 182 | return ( |
| 183 | <KanbanCard |
| 184 | key={id} |
| 185 | id={id} |
| 186 | cardRefs={cardRefs} |
| 187 | lifted={lifted?.id === id} |
| 188 | reduceMotion={reduceMotion} |
| 189 | onOpen={onOpen ? () => onOpen(item) : undefined} |
| 190 | onKeyDown={(event) => handleKey(item, event)} |
| 191 | onDragStart={() => setDragging({ id, target: null })} |
| 192 | onDrag={(x, y) => { |
| 193 | const target = positionAt(x, y, id); |
| 194 | setDragging((current) => |
| 195 | current?.target?.columnId === target?.columnId && current?.target?.index === target?.index |
| 196 | ? current |
| 197 | : { id, target }, |
| 198 | ); |
| 199 | }} |
| 200 | onDragEnd={(x, y) => { |
| 201 | const destination = positionAt(x, y, id); |
| 202 | setDragging(null); |
| 203 | if (destination) commit(id, destination); |
| 204 | }} |
| 205 | > |
| 206 | {renderCard(item, { moving: dragging?.id === id || lifted?.id === id })} |
| 207 | </KanbanCard> |
| 208 | ); |
| 209 | }; |
| 210 |
| 211 | return ( |
| 212 | <LayoutGroup> |
| 213 | <div role="region" aria-label={label} className={cn("flex min-h-0 gap-3 overflow-x-auto pb-2", className)}> |
| 214 | <p aria-live="assertive" className="sr-only"> |
| 215 | {announcement} |
| 216 | </p> |
| 217 | {columns.map((column) => { |
| 218 | const cards = byColumn.get(column.id) ?? []; |
| 219 | const nodes: React.ReactNode[] = []; |
| 220 | if (lifted) { |
| 221 | // The lifted card sits wherever it is headed. |
| 222 | for (const item of othersIn(column.id, lifted.id)) nodes.push(card(item)); |
| 223 | if (lifted.target.columnId === column.id) { |
| 224 | const liftedItem = items.find((item) => getItemId(item) === lifted.id); |
| 225 | if (liftedItem) nodes.splice(lifted.target.index, 0, card(liftedItem)); |
| 226 | } |
| 227 | } else { |
| 228 | // A dragged card stays under the pointer; a slot opens where it will land. |
| 229 | for (const item of cards) nodes.push(card(item)); |
| 230 | const target = dragging?.target; |
| 231 | if (dragging && target?.columnId === column.id && !isOrigin(dragging.id, target)) { |
| 232 | const before = othersIn(column.id, dragging.id)[target.index]; |
| 233 | nodes.splice(before ? cards.indexOf(before) : cards.length, 0, <DropSlot key="kanban-slot" />); |
| 234 | } |
| 235 | } |
| 236 | const receiving = (dragging?.target?.columnId ?? lifted?.target.columnId) === column.id; |
| 237 | return ( |
| 238 | <section |
| 239 | key={column.id} |
| 240 | aria-label={column.title} |
| 241 | className={cn( |
| 242 | "flex w-72 shrink-0 flex-col rounded-2xl bg-muted/40 transition-colors duration-200", |
| 243 | receiving && "bg-muted/70", |
| 244 | )} |
| 245 | > |
| 246 | <header className="flex items-center gap-2 px-3 pt-3 pb-2"> |
| 247 | <span className={cn("size-2 rounded-full", TONE_DOT[column.tone ?? "default"])} /> |
| 248 | <h3 className="truncate text-sm font-medium text-foreground">{column.title}</h3> |
| 249 | <motion.span |
| 250 | key={cards.length} |
| 251 | initial={reduceMotion ? false : { scale: 0.7, opacity: 0 }} |
| 252 | animate={{ scale: 1, opacity: 1 }} |
| 253 | transition={transitions.spring} |
| 254 | className="ml-auto rounded-md bg-background/70 px-1.5 text-xs font-medium tabular-nums text-muted-foreground" |
| 255 | > |
| 256 | {cards.length} |
| 257 | </motion.span> |
| 258 | </header> |
| 259 | <div |
| 260 | ref={(element) => { |
| 261 | if (element) columnRefs.current.set(column.id, element); |
| 262 | else columnRefs.current.delete(column.id); |
| 263 | }} |
| 264 | className="flex min-h-24 flex-1 flex-col gap-2 px-2 pb-2" |
| 265 | > |
| 266 | <AnimatePresence initial={false}>{nodes}</AnimatePresence> |
| 267 | {cards.length === 0 && !receiving ? ( |
| 268 | <p className="flex flex-1 items-center justify-center rounded-xl border border-dashed border-border/70 py-6 text-xs text-muted-foreground"> |
| 269 | Nothing here |
| 270 | </p> |
| 271 | ) : null} |
| 272 | {columnFooter ? <div className="mt-auto pt-1">{columnFooter(column)}</div> : null} |
| 273 | </div> |
| 274 | </section> |
| 275 | ); |
| 276 | })} |
| 277 | </div> |
| 278 | </LayoutGroup> |
| 279 | ); |
| 280 | } |
| 281 |
| 282 | function DropSlot() { |
| 283 | return ( |
| 284 | <motion.div |
| 285 | layout |
| 286 | initial={{ opacity: 0, height: 0 }} |
| 287 | animate={{ opacity: 1, height: 52 }} |
| 288 | exit={{ opacity: 0, height: 0 }} |
| 289 | transition={transitions.enter} |
| 290 | className="shrink-0 rounded-xl border border-dashed border-action/50 bg-action/[0.05]" |
| 291 | /> |
| 292 | ); |
| 293 | } |
| 294 |
| 295 | function KanbanCard({ |
| 296 | id, |
| 297 | children, |
| 298 | cardRefs, |
| 299 | lifted, |
| 300 | reduceMotion, |
| 301 | onOpen, |
| 302 | onKeyDown, |
| 303 | onDragStart, |
| 304 | onDrag, |
| 305 | onDragEnd, |
| 306 | }: { |
| 307 | id: string; |
| 308 | children: React.ReactNode; |
| 309 | cardRefs: React.RefObject<Map<string, HTMLElement>>; |
| 310 | lifted: boolean; |
| 311 | reduceMotion: boolean; |
| 312 | onOpen: (() => void) | undefined; |
| 313 | onKeyDown: (event: React.KeyboardEvent) => void; |
| 314 | onDragStart: () => void; |
| 315 | onDrag: (x: number, y: number) => void; |
| 316 | onDragEnd: (x: number, y: number) => void; |
| 317 | }) { |
| 318 | const dragged = React.useRef(false); |
| 319 | const element = React.useRef<HTMLDivElement | null>(null); |
| 320 | React.useEffect(() => { |
| 321 | if (lifted) element.current?.focus(); |
| 322 | }, [lifted]); |
| 323 | return ( |
| 324 | <motion.div |
| 325 | ref={(node: HTMLDivElement | null) => { |
| 326 | element.current = node; |
| 327 | if (node) cardRefs.current.set(id, node); |
| 328 | else cardRefs.current.delete(id); |
| 329 | }} |
| 330 | layout={reduceMotion ? false : "position"} |
| 331 | {...(reduceMotion ? {} : { layoutId: `kanban-${id}` })} |
| 332 | initial={reduceMotion ? false : { opacity: 0, scale: 0.98 }} |
| 333 | animate={{ opacity: 1, scale: lifted ? 1.02 : 1 }} |
| 334 | exit={{ opacity: 0, scale: 0.98 }} |
| 335 | drag |
| 336 | dragSnapToOrigin |
| 337 | dragElastic={0.1} |
| 338 | dragMomentum={false} |
| 339 | whileDrag={{ scale: 1.03, rotate: 1.5, zIndex: 50 }} |
| 340 | transition={transitions.spring} |
| 341 | onPointerDown={() => { |
| 342 | dragged.current = false; |
| 343 | }} |
| 344 | onDragStart={() => { |
| 345 | dragged.current = true; |
| 346 | onDragStart(); |
| 347 | }} |
| 348 | onDrag={(_event, info) => onDrag(info.point.x - window.scrollX, info.point.y - window.scrollY)} |
| 349 | onDragEnd={(_event, info) => onDragEnd(info.point.x - window.scrollX, info.point.y - window.scrollY)} |
| 350 | onClick={() => { |
| 351 | if (!dragged.current) onOpen?.(); |
| 352 | }} |
| 353 | onKeyDown={onKeyDown} |
| 354 | tabIndex={0} |
| 355 | role="button" |
| 356 | aria-roledescription="Draggable card" |
| 357 | aria-pressed={lifted} |
| 358 | className={cn( |
| 359 | "relative cursor-grab touch-none rounded-xl border border-border/60 bg-card p-3 text-sm shadow-xs outline-none transition-[border-color,box-shadow] duration-150 select-none hover:border-border focus-visible:ring-2 focus-visible:ring-ring/60 active:cursor-grabbing", |
| 360 | lifted && "shadow-lg ring-2 ring-action/60", |
| 361 | )} |
| 362 | > |
| 363 | {children} |
| 364 | </motion.div> |
| 365 | ); |
| 366 | } |
| 367 |