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']); }); }); });