import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SortableTable } from './sortable-table';
import {
SORTABLE_TABLE_REORDER_EVENT,
SORTABLE_TABLE_RESIZE_EVENT,
type SortableTableReorderEvent,
type SortableTableResizeEvent,
} from './sortable-table.types';
const MARKUP = `
|
Name
|
Age
|
| Ada |
36 |
| Linus |
54 |
| Grace |
85 |
`;
function setup(): HTMLElement {
document.body.innerHTML = MARKUP;
return document.body.firstElementChild as HTMLElement;
}
function rowHandle(root: HTMLElement, value: string): HTMLButtonElement {
return root
.querySelector(`[data-c42-sortable-row][data-value="${value}"]`)!
.querySelector('[data-c42-sortable-row-handle]')!;
}
function colHandle(root: HTMLElement, value: string): HTMLButtonElement {
return root
.querySelector(`[data-c42-sortable-col][data-value="${value}"]`)!
.querySelector('[data-c42-sortable-col-handle]')!;
}
function press(el: HTMLElement, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
}
const FULL = { reorderRows: true, reorderColumns: true, resizeColumns: true };
describe('SortableTable', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('reports the initial row and column order', () => {
const root = setup();
const table = new SortableTable(root, FULL);
expect(table.getRowOrder()).toEqual(['r1', 'r2', 'r3']);
expect(table.getColumnOrder()).toEqual(['name', 'age']);
});
it('moveRow reorders rows and emits', () => {
const root = setup();
const table = new SortableTable(root, FULL);
const spy = vi.fn();
table.on(SORTABLE_TABLE_REORDER_EVENT, spy);
table.moveRow('r1', 'r3');
expect(table.getRowOrder()).toEqual(['r2', 'r3', 'r1']);
expect(spy.mock.calls[0][0].detail).toMatchObject({ axis: 'row', order: ['r2', 'r3', 'r1'] });
});
it('moves a row up with ArrowUp on its handle', () => {
const root = setup();
const table = new SortableTable(root, FULL);
press(rowHandle(root, 'r2'), 'ArrowUp');
expect(table.getRowOrder()).toEqual(['r2', 'r1', 'r3']);
});
it('moves a row down with ArrowDown on its handle', () => {
const root = setup();
const table = new SortableTable(root, FULL);
press(rowHandle(root, 'r2'), 'ArrowDown');
expect(table.getRowOrder()).toEqual(['r1', 'r3', 'r2']);
});
it('moves a row to the start/end with Home/End', () => {
const root = setup();
const table = new SortableTable(root, FULL);
press(rowHandle(root, 'r3'), 'Home');
expect(table.getRowOrder()).toEqual(['r3', 'r1', 'r2']);
press(rowHandle(root, 'r3'), 'End');
expect(table.getRowOrder()).toEqual(['r1', 'r2', 'r3']);
});
it('moveColumn reorders the header and every body cell', () => {
const root = setup();
const table = new SortableTable(root, FULL);
table.moveColumn('name', 'age');
expect(table.getColumnOrder()).toEqual(['age', 'name']);
const firstBodyRow = root.querySelector('[data-c42-sortable-row]')!;
const cells = Array.from(firstBodyRow.children).map((c) => c.textContent?.trim());
expect(cells[0]).toBe('36');
});
it('moves a column with arrow keys on the column handle', () => {
const root = setup();
const table = new SortableTable(root, FULL);
press(colHandle(root, 'age'), 'ArrowLeft');
expect(table.getColumnOrder()).toEqual(['age', 'name']);
});
it('setColumnWidth sets width and emits resize', () => {
const root = setup();
const table = new SortableTable(root, FULL);
const spy = vi.fn();
table.on(SORTABLE_TABLE_RESIZE_EVENT, spy);
table.setColumnWidth('name', 220);
const th = root.querySelector('[data-c42-sortable-col][data-value="name"]')!;
expect(th.style.width).toBe('220px');
expect(spy.mock.calls[0][0].detail).toEqual({ value: 'name', width: 220 });
});
it('clamps width to the minimum', () => {
const root = setup();
const table = new SortableTable(root, { ...FULL, minColumnWidth: 60 });
table.setColumnWidth('name', 10);
const th = root.querySelector('[data-c42-sortable-col][data-value="name"]')!;
expect(th.style.width).toBe('60px');
});
it('resizes a column with arrow keys on the resize handle', () => {
const root = setup();
new SortableTable(root, { ...FULL, resizeStep: 20, minColumnWidth: 50 });
const th = root.querySelector('[data-c42-sortable-col][data-value="name"]')!;
const handle = th.querySelector('[data-c42-sortable-resize]')!;
press(handle, 'ArrowRight');
// starts from minColumnWidth (50, no measurable rect in jsdom) + 20
expect(th.style.width).toBe('70px');
});
it('wires resize handle ARIA', () => {
const root = setup();
new SortableTable(root, FULL);
const handle = root.querySelector('[data-c42-sortable-resize]')!;
expect(handle.getAttribute('role')).toBe('separator');
expect(handle.getAttribute('aria-orientation')).toBe('vertical');
expect(handle.getAttribute('tabindex')).toBe('0');
});
it('registers rows and columns as draggable (pragmatic-dnd)', () => {
const root = setup();
new SortableTable(root, FULL);
const row = root.querySelector('[data-c42-sortable-row][data-value="r1"]')!;
const th = root.querySelector('[data-c42-sortable-col][data-value="name"]')!;
expect(row.getAttribute('draggable')).toBe('true');
expect(th.getAttribute('draggable')).toBe('true');
});
it('does not register row draggables when reorderRows is false', () => {
const root = setup();
new SortableTable(root, { reorderRows: false });
const row = root.querySelector('[data-c42-sortable-row][data-value="r1"]')!;
expect(row.hasAttribute('draggable')).toBe(false);
});
it('does not wire row handles when reorderRows is false', () => {
const root = setup();
const table = new SortableTable(root, { reorderRows: false });
press(rowHandle(root, 'r2'), 'ArrowUp');
expect(table.getRowOrder()).toEqual(['r1', 'r2', 'r3']);
});
it('removes listeners and drag wiring on destroy', () => {
const root = setup();
const table = new SortableTable(root, FULL);
const row = root.querySelector('[data-c42-sortable-row][data-value="r1"]')!;
expect(row.getAttribute('draggable')).toBe('true');
const spy = vi.fn();
table.on(SORTABLE_TABLE_REORDER_EVENT, spy);
table.destroy();
expect(row.hasAttribute('draggable')).toBe(false);
press(rowHandle(root, 'r2'), 'ArrowUp');
expect(spy).not.toHaveBeenCalled();
expect(table.getRowOrder()).toEqual(['r1', 'r2', 'r3']);
});
});