import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements, monitorForElements, } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { attachClosestEdge, extractClosestEdge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { announce } from '@atlaskit/pragmatic-drag-and-drop-live-region'; import { uid } from '../shared/dom'; import { clearDragging, clearDropTarget, flashDropped, markDragging, markDropTarget, } from '../shared/dnd'; import { KANBAN_MOVE_EVENT, type KanbanMoveDetail, type KanbanOptions } from './kanban.types'; const SELECTORS = { column: '[data-c42-kanban-column]', list: '[data-c42-kanban-list]', item: '[data-c42-kanban-item]', } as const; /** Minimal shape of a pragmatic-dnd drop target record we rely on. */ interface DropRecord { element: Element; data: Record; } /** * Headless kanban board controller. Moves items between columns and reorders * them within a column via pointer drag (powered by * `@atlaskit/pragmatic-drag-and-drop`) and keyboard, reflecting `data-dragging` * and emitting a typed move event — it applies no visual styles. * * Markup: * ```html *
*
*
*
First task
*
Second task
*
*
*
*
*
*
* ``` */ export class Kanban { private readonly root: HTMLElement; private readonly dragEnabled: boolean; private readonly keyboardEnabled: boolean; /** Scopes pragmatic-dnd interactions to this board instance. */ private readonly dndKey = uid('kanban'); private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: KanbanOptions = {}) { if (root.querySelector(SELECTORS.column) === null) { throw new Error('[42/kanban] Needs at least one [data-c42-kanban-column].'); } this.root = root; this.dragEnabled = options.drag ?? true; this.keyboardEnabled = options.keyboard ?? true; this.init(); } private init(): void { this.columns().forEach((column) => { column.setAttribute('role', 'group'); const list = this.listOf(column); if (list && !list.hasAttribute('role')) { list.setAttribute('role', 'list'); } }); this.items().forEach((item) => this.prepareItem(item)); if (this.dragEnabled) { this.setupDnd(); } } private prepareItem(item: HTMLElement): void { if (!item.hasAttribute('tabindex')) { item.setAttribute('tabindex', '0'); } item.setAttribute('aria-roledescription', 'Draggable item'); if (this.keyboardEnabled) { const onKeydown = (event: Event): void => this.onKeydown(event as KeyboardEvent, item); item.addEventListener('keydown', onKeydown); this.cleanups.push(() => item.removeEventListener('keydown', onKeydown)); } } // ─── Queries ─────────────────────────────────────────────────────────── private columns(): HTMLElement[] { return Array.from(this.root.querySelectorAll(SELECTORS.column)); } private items(): HTMLElement[] { return Array.from(this.root.querySelectorAll(SELECTORS.item)); } private listOf(column: HTMLElement): HTMLElement | null { return column.querySelector(SELECTORS.list); } private columnOfList(list: HTMLElement): string { const column = list.closest(SELECTORS.column); return column?.dataset.value ?? ''; } private itemsIn(list: HTMLElement): HTMLElement[] { return Array.from(list.querySelectorAll(SELECTORS.item)); } private listForColumn(value: string): HTMLElement | null { const column = this.columns().find((c) => c.dataset.value === value); return column ? this.listOf(column) : null; } private itemValue(item: HTMLElement, list: HTMLElement): string { return item.dataset.value ?? String(this.itemsIn(list).indexOf(item)); } /** Current item `data-value`s for every column, keyed by column value. */ getOrder(): Record { const order: Record = {}; this.columns().forEach((column) => { const value = column.dataset.value ?? ''; const list = this.listOf(column); order[value] = list ? this.itemsIn(list).map((item) => this.itemValue(item, list)) : []; }); return order; } // ─── DOM mutation ──────────────────────────────────────────────────────── /** Insert `item` into `list` at `index` (clamped). No event. */ private insertInto(item: HTMLElement, list: HTMLElement, index: number): void { const siblings = this.itemsIn(list).filter((el) => el !== item); const clamped = Math.max(0, Math.min(index, siblings.length)); if (clamped >= siblings.length) { list.appendChild(item); } else { list.insertBefore(item, siblings[clamped]!); } } private emitMove(item: HTMLElement, from: string, fromIndex: number): boolean { const list = item.parentElement; if (!(list instanceof HTMLElement)) { return false; } const to = this.columnOfList(list); const toIndex = this.itemsIn(list).indexOf(item); if (from === to && fromIndex === toIndex) { return false; } const detail: KanbanMoveDetail = { item: this.itemValue(item, list), from, to, fromIndex, toIndex, order: this.getOrder(), }; this.root.dispatchEvent(new CustomEvent(KANBAN_MOVE_EVENT, { detail, bubbles: true })); return true; } private positionOf( item: HTMLElement, ): { list: HTMLElement; column: string; index: number } | null { const list = item.parentElement; if (!(list instanceof HTMLElement) || !list.matches(SELECTORS.list)) { return null; } return { list, column: this.columnOfList(list), index: this.itemsIn(list).indexOf(item), }; } // ─── Public API ────────────────────────────────────────────────────────── /** * Move an item (by `data-value`) into a column (by `data-value`) at an * optional index (defaults to the end). Emits `kanban:move` when something * actually changes. */ moveItem(itemValue: string, toColumnValue: string, toIndex = Number.MAX_SAFE_INTEGER): boolean { const item = this.items().find((el) => el.dataset.value === itemValue); const targetList = this.listForColumn(toColumnValue); if (!item || !targetList) { return false; } const before = this.positionOf(item); if (!before) { return false; } this.insertInto(item, targetList, toIndex); return this.emitMove(item, before.column, before.index); } // ─── Keyboard ────────────────────────────────────────────────────────── private onKeydown(event: KeyboardEvent, item: HTMLElement): void { const pos = this.positionOf(item); if (!pos) { return; } const total = this.itemsIn(pos.list).length; let handled = true; switch (event.key) { case 'ArrowUp': if (pos.index > 0) { this.insertInto(item, pos.list, pos.index - 1); this.emitMove(item, pos.column, pos.index); } break; case 'ArrowDown': if (pos.index < total - 1) { this.insertInto(item, pos.list, pos.index + 1); this.emitMove(item, pos.column, pos.index); } break; case 'Home': if (pos.index > 0) { this.insertInto(item, pos.list, 0); this.emitMove(item, pos.column, pos.index); } break; case 'End': if (pos.index < total - 1) { this.insertInto(item, pos.list, total); this.emitMove(item, pos.column, pos.index); } break; case 'ArrowLeft': this.moveToAdjacentColumn(item, pos, -1); break; case 'ArrowRight': this.moveToAdjacentColumn(item, pos, 1); break; default: handled = false; break; } if (handled) { event.preventDefault(); item.focus(); } } private moveToAdjacentColumn( item: HTMLElement, pos: { list: HTMLElement; column: string; index: number }, direction: -1 | 1, ): void { const columns = this.columns(); const currentColumnIndex = columns.findIndex((c) => c.dataset.value === pos.column); const targetColumn = columns[currentColumnIndex + direction]; if (!targetColumn) { return; } const targetList = this.listOf(targetColumn); if (!targetList) { return; } this.insertInto(item, targetList, pos.index); this.emitMove(item, pos.column, pos.index); } // ─── Pointer drag (pragmatic-drag-and-drop) ────────────────────────────── private setupDnd(): void { this.items().forEach((item) => { this.cleanups.push( combine( draggable({ element: item, getInitialData: () => ({ board: this.dndKey, kind: 'item' }), onDragStart: () => markDragging(item), onDrop: () => clearDragging(item), }), dropTargetForElements({ element: item, canDrop: ({ source }) => source.data.board === this.dndKey && source.element !== item, getData: ({ input, element }) => attachClosestEdge( { board: this.dndKey, kind: 'item' }, { input, element, allowedEdges: ['top', 'bottom'] }, ), getIsSticky: () => true, onDragEnter: ({ self }) => markDropTarget(item, self.data), onDrag: ({ self }) => markDropTarget(item, self.data), onDragLeave: () => clearDropTarget(item), onDrop: () => clearDropTarget(item), }), ), ); }); this.columns().forEach((column) => { const list = this.listOf(column); if (!list) { return; } this.cleanups.push( combine( dropTargetForElements({ element: list, canDrop: ({ source }) => source.data.board === this.dndKey, getData: () => ({ board: this.dndKey, kind: 'list' }), onDragEnter: () => markDropTarget(list), onDragLeave: () => clearDropTarget(list), onDrop: () => clearDropTarget(list), }), autoScrollForElements({ element: list }), ), ); }); this.cleanups.push( monitorForElements({ canMonitor: ({ source }) => source.data.board === this.dndKey, onDrop: ({ source, location }) => this.applyDrop(source.element as HTMLElement, location.current.dropTargets), }), ); } /** Apply a completed pointer drop: compute the destination list + index and move. */ private applyDrop(item: HTMLElement, dropTargets: readonly DropRecord[]): void { const innermost = dropTargets[0]; if (!innermost) { return; } const before = this.positionOf(item); if (!before) { return; } let list: HTMLElement | null; let index: number; if (innermost.data.kind === 'item') { const overItem = innermost.element as HTMLElement; list = overItem.parentElement instanceof HTMLElement ? overItem.parentElement : null; if (!list) { return; } const siblings = this.itemsIn(list).filter((el) => el !== item); const overIndex = siblings.indexOf(overItem); if (overIndex < 0) { index = siblings.length; } else { index = extractClosestEdge(innermost.data) === 'bottom' ? overIndex + 1 : overIndex; } } else { list = innermost.element as HTMLElement; index = Number.MAX_SAFE_INTEGER; } this.insertInto(item, list, index); if (this.emitMove(item, before.column, before.index)) { flashDropped(item); announce(`Moved ${this.itemValue(item, list)} to ${this.columnOfList(list)}.`); } } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.items().forEach((item) => { clearDragging(item); clearDropTarget(item); }); this.columns().forEach((column) => { const list = this.listOf(column); if (list) { clearDropTarget(list); } }); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }