import React from 'react';
import { act, fireEvent } from '@testing-library/react';
import { render } from '../../../vitest/render';
import { ReorderableListing, reorder } from './ReorderableListing';
import type { ReorderableListingItemData } from './ReorderableListing';
beforeAll(() => {
if (globalThis.CSS === undefined) {
Object.defineProperty(globalThis, 'CSS', {
writable: true,
value: { escape: (value: string) => value.replaceAll(/[^a-zA-Z0-9_-]/g, char => `\\${char}`) },
});
}
});
const items: ReorderableListingItemData[] = [
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta' },
];
const nested: ReorderableListingItemData[] = [{ id: 'p', label: 'Parent', children: [{ id: 'c', label: 'Child' }] }];
describe('ReorderableListing', () => {
describe('hide toggle — uncontrolled', () => {
it('fires onHiddenChange with (id, true) and flips the row to the shown state', () => {
const onHiddenChange = vi.fn();
const { getAllByRole, getByRole, queryByRole } = render(
);
fireEvent.click(getAllByRole('button', { name: 'Hide' })[0] as HTMLElement);
expect(onHiddenChange).toHaveBeenCalledTimes(1);
expect(onHiddenChange).toHaveBeenCalledWith('a', true, undefined);
expect(getByRole('button', { name: 'Show' })).toBeTruthy();
expect(queryByRole('button', { name: 'Hide' })).toBeTruthy(); // the other row is still hideable
});
});
describe('hide toggle — controlled', () => {
it('notifies but does not mutate internal state', () => {
const onHiddenChange = vi.fn();
const { getByRole, queryByRole } = render(
);
fireEvent.click(getByRole('button', { name: 'Hide' }));
expect(onHiddenChange).toHaveBeenCalledWith('a', true, undefined);
expect(getByRole('button', { name: 'Hide' })).toBeTruthy();
expect(queryByRole('button', { name: 'Show' })).toBeNull();
});
});
describe('subitems', () => {
it('reports the parentId when a subitem hide action is toggled', () => {
const onHiddenChange = vi.fn();
const { getAllByRole } = render(
);
fireEvent.click(getAllByRole('button', { name: 'Hide' })[1] as HTMLElement);
expect(onHiddenChange).toHaveBeenCalledWith('c', true, 'p');
});
it('suppresses the subitem hide control while the parent is hidden', () => {
const { getByText, queryByRole, getAllByRole } = render(
);
expect(getByText('Child')).toBeTruthy();
expect(queryByRole('button', { name: 'Hide' })).toBeNull();
expect(getAllByRole('button', { name: 'Show' })).toHaveLength(1);
});
});
describe('disabled rows', () => {
it('renders no hide control for a disabled item', () => {
const { queryByRole } = render(
);
expect(queryByRole('button', { name: 'Hide' })).toBeNull();
expect(queryByRole('button', { name: 'Show' })).toBeNull();
});
});
describe('renderActions slot', () => {
it('replaces the built-in hide button with custom actions', () => {
const { getByText, queryByRole } = render(
}
/>
);
expect(getByText('Custom')).toBeTruthy();
expect(queryByRole('button', { name: 'Hide' })).toBeNull();
});
});
describe('footer', () => {
it('is absent by default and renders the provided footer node', () => {
const onFooterClick = vi.fn();
const { queryByRole, rerender, getByRole } = render(
);
expect(queryByRole('button', { name: 'Footer action' })).toBeNull();
rerender(
Footer action
}
/>
);
fireEvent.click(getByRole('button', { name: 'Footer action' }));
expect(onFooterClick).toHaveBeenCalledTimes(1);
});
});
describe('grid & drag-and-drop wiring', () => {
it('renders a labelled grid with one row > gridcell pair per item and accessible drag handles', () => {
const { getByRole, getAllByRole } = render(
);
expect(getByRole('grid', { name: 'Reorderable list' })).toBeTruthy();
const rows = getAllByRole('row');
expect(rows.map(row => row.getAttribute('aria-label'))).toEqual(['Alpha', 'Beta']);
rows.forEach(row => {
expect(row.querySelector('[role="gridcell"]')).not.toBeNull();
expect(row.getAttribute('draggable')).toBe('true');
});
expect(getAllByRole('button', { name: 'Drag to reorder' })).toHaveLength(2);
});
it('labels the grid via the aria-label prop', () => {
const { getByRole } = render();
expect(getByRole('grid', { name: 'Menu items' })).toBeTruthy();
});
it('renders disabled rows inert: not draggable, with a disabled drag handle', () => {
const { getAllByRole } = render(
);
const [alpha, beta] = getAllByRole('row');
expect(alpha?.getAttribute('draggable')).toBe('true');
expect(beta?.getAttribute('draggable')).toBeNull();
expect(beta?.getAttribute('aria-disabled')).toBe('true');
const handles = getAllByRole('button', { name: 'Drag to reorder', hidden: true }) as HTMLButtonElement[];
expect(handles.map(handle => handle.disabled)).toEqual([false, true]);
});
it('renders draggable: false rows as reorder-inert but otherwise fully interactive', () => {
const { getAllByRole } = render(
);
const [alpha, beta] = getAllByRole('row');
expect(alpha?.getAttribute('draggable')).toBe('true');
expect(beta?.getAttribute('draggable')).toBeNull();
// unlike a disabled row, it stays focusable/selectable — no aria-disabled
expect(beta?.getAttribute('aria-disabled')).toBeNull();
const handles = getAllByRole('button', { name: 'Drag to reorder', hidden: true }) as HTMLButtonElement[];
expect(handles.map(handle => handle.disabled)).toEqual([false, true]);
// keeps its own actions, unlike a disabled row
expect(getAllByRole('button', { name: 'Hide' })).toHaveLength(2);
});
it('reorders via keyboard drag and drop and reports the new order through onReorder', () => {
const onReorder = vi.fn();
const { getAllByRole } = render(
);
const handle = getAllByRole('button', { name: 'Drag to reorder' })[0] as HTMLElement;
act(() => handle.focus());
fireEvent.keyDown(handle, { key: 'Enter' });
fireEvent.keyUp(handle, { key: 'Enter' });
act(() => vi.runAllTimers());
const active = () => document.activeElement as HTMLElement;
fireEvent.keyDown(active(), { key: 'ArrowDown' });
fireEvent.keyUp(active(), { key: 'ArrowDown' });
act(() => vi.runAllTimers());
fireEvent.keyDown(active(), { key: 'Enter' });
fireEvent.keyUp(active(), { key: 'Enter' });
act(() => vi.runAllTimers());
expect(onReorder).toHaveBeenCalledTimes(1);
const [orderedIds, orderedItems] = onReorder.mock.calls[0] as [string[], ReorderableListingItemData[]];
expect(orderedIds).toEqual(['b', 'a']);
expect(orderedItems.map(item => item.id)).toEqual(['b', 'a']);
expect(getAllByRole('row').map(row => row.getAttribute('aria-label'))).toEqual(['Beta', 'Alpha']);
});
});
describe('reorder helper', () => {
const list: ReorderableListingItemData[] = [
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta' },
{ id: 'c', label: 'Gamma' },
];
const ids = (result: ReorderableListingItemData[]) => result.map(item => item.id);
it('moves an item before the target key', () => {
expect(ids(reorder(list, new Set(['c']), 'a', 'before'))).toEqual(['c', 'a', 'b']);
});
it('moves an item after the target key', () => {
expect(ids(reorder(list, new Set(['a']), 'b', 'after'))).toEqual(['b', 'a', 'c']);
});
it('moves multiple items together, preserving their original order', () => {
expect(ids(reorder(list, new Set(['a', 'c']), 'b', 'after'))).toEqual(['b', 'a', 'c']);
});
it('returns the original list unchanged when the target key is unknown', () => {
const result = reorder(list, new Set(['a']), 'missing', 'before');
expect(result).toBe(list);
expect(ids(result)).toEqual(['a', 'b', 'c']);
});
});
});