import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Kanban } from './kanban';
import { KANBAN_MOVE_EVENT, type KanbanMoveEvent } from './kanban.types';
const MARKUP = `
`;
function setup(): HTMLElement {
document.body.innerHTML = MARKUP;
return document.body.firstElementChild as HTMLElement;
}
function item(root: HTMLElement, value: string): HTMLElement {
return root.querySelector(`[data-c42-kanban-item][data-value="${value}"]`)!;
}
function columnOrder(root: HTMLElement, column: string): string[] {
const col = root.querySelector(`[data-c42-kanban-column][data-value="${column}"]`)!;
return Array.from(col.querySelectorAll('[data-c42-kanban-item]')).map(
(el) => el.dataset.value!,
);
}
function press(el: HTMLElement, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
}
describe('Kanban', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('wires roles and makes items focusable', () => {
const root = setup();
new Kanban(root);
expect(root.querySelector('[data-c42-kanban-column]')!.getAttribute('role')).toBe('group');
expect(root.querySelector('[data-c42-kanban-list]')!.getAttribute('role')).toBe('list');
expect(item(root, 'a').getAttribute('tabindex')).toBe('0');
expect(item(root, 'a').getAttribute('aria-roledescription')).toBe('Draggable item');
});
it('throws when there are no columns', () => {
document.body.innerHTML = '';
const root = document.body.firstElementChild as HTMLElement;
expect(() => new Kanban(root)).toThrow(/column/);
});
it('reports the current order per column', () => {
const root = setup();
const board = new Kanban(root);
expect(board.getOrder()).toEqual({ todo: ['a', 'b', 'c'], doing: ['d'], done: [] });
});
it('reorders within a column with ArrowDown/ArrowUp', () => {
const root = setup();
new Kanban(root);
press(item(root, 'a'), 'ArrowDown');
expect(columnOrder(root, 'todo')).toEqual(['b', 'a', 'c']);
press(item(root, 'a'), 'ArrowUp');
expect(columnOrder(root, 'todo')).toEqual(['a', 'b', 'c']);
});
it('jumps to start/end of a column with Home/End', () => {
const root = setup();
new Kanban(root);
press(item(root, 'a'), 'End');
expect(columnOrder(root, 'todo')).toEqual(['b', 'c', 'a']);
press(item(root, 'a'), 'Home');
expect(columnOrder(root, 'todo')).toEqual(['a', 'b', 'c']);
});
it('moves to an adjacent column with ArrowRight/ArrowLeft', () => {
const root = setup();
new Kanban(root);
press(item(root, 'b'), 'ArrowRight'); // todo index 1 -> doing index 1
expect(columnOrder(root, 'todo')).toEqual(['a', 'c']);
expect(columnOrder(root, 'doing')).toEqual(['d', 'b']);
press(item(root, 'b'), 'ArrowLeft'); // back toward todo
expect(columnOrder(root, 'doing')).toEqual(['d']);
expect(columnOrder(root, 'todo')).toContain('b');
});
it('does not move past the first or last column', () => {
const root = setup();
const board = new Kanban(root);
press(item(root, 'a'), 'ArrowLeft'); // already leftmost column
expect(columnOrder(root, 'todo')).toEqual(['a', 'b', 'c']);
board.moveItem('d', 'done');
press(item(root, 'd'), 'ArrowRight'); // already rightmost column
expect(columnOrder(root, 'done')).toEqual(['d']);
});
it('emits a move event with full detail', () => {
const root = setup();
const board = new Kanban(root);
const spy = vi.fn();
board.on(KANBAN_MOVE_EVENT, spy);
press(item(root, 'a'), 'ArrowRight'); // todo[0] -> doing[0]
expect(spy).toHaveBeenCalledOnce();
const detail = spy.mock.calls[0][0].detail;
expect(detail.item).toBe('a');
expect(detail.from).toBe('todo');
expect(detail.to).toBe('doing');
expect(detail.fromIndex).toBe(0);
expect(detail.toIndex).toBe(0);
expect(detail.order.doing).toEqual(['a', 'd']);
});
it('does not emit when nothing changes', () => {
const root = setup();
const board = new Kanban(root);
const spy = vi.fn();
board.on(KANBAN_MOVE_EVENT, spy);
press(item(root, 'a'), 'ArrowUp'); // already at top
expect(spy).not.toHaveBeenCalled();
});
it('moves programmatically via moveItem at a given index', () => {
const root = setup();
const board = new Kanban(root);
expect(board.moveItem('c', 'doing', 0)).toBe(true);
expect(columnOrder(root, 'doing')).toEqual(['c', 'd']);
expect(columnOrder(root, 'todo')).toEqual(['a', 'b']);
});
it('moveItem returns false for unknown item or column', () => {
const root = setup();
const board = new Kanban(root);
expect(board.moveItem('zzz', 'doing')).toBe(false);
expect(board.moveItem('a', 'nope')).toBe(false);
});
it('registers items as draggable when drag is enabled (pragmatic-dnd)', () => {
const root = setup();
new Kanban(root, { drag: true });
expect(item(root, 'a').getAttribute('draggable')).toBe('true');
expect(item(root, 'd').getAttribute('draggable')).toBe('true');
});
it('does not register draggables when drag is disabled', () => {
const root = setup();
new Kanban(root, { drag: false });
expect(item(root, 'a').hasAttribute('draggable')).toBe(false);
});
it('removes listeners and drag wiring on destroy', () => {
const root = setup();
const board = new Kanban(root);
expect(item(root, 'a').getAttribute('draggable')).toBe('true');
board.destroy();
expect(item(root, 'a').hasAttribute('draggable')).toBe(false);
press(item(root, 'a'), 'ArrowDown');
expect(columnOrder(root, 'todo')).toEqual(['a', 'b', 'c']);
});
});