Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions src/pages/Ledger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -621,4 +621,171 @@ describe('Ledger Component', () => {
expect(screen.getByText('1 / 5')).toBeInTheDocument();
});
});


// ────────────────────────────────────
// 16. Keyboard row selection (Issue #466)
// ────────────────────────────────────
describe('keyboard row selection', () => {
it('selects a row on click', () => {
render(<Ledger />);
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));
expect(rows.length).toBeGreaterThan(0);

// Click the first data row (not on a button)
const firstRow = rows[0];
const dateCell = firstRow.querySelector('td:nth-child(2)'); // date column, skip expand button
fireEvent.click(dateCell!);

expect(firstRow).toHaveAttribute('aria-selected', 'true');
});

it('deselects a row on second click', () => {
render(<Ledger />);
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));
const firstRow = rows[0];
const dateCell = firstRow.querySelector('td:nth-child(2)');

fireEvent.click(dateCell!);
expect(firstRow).toHaveAttribute('aria-selected', 'true');

fireEvent.click(dateCell!);
expect(firstRow).toHaveAttribute('aria-selected', 'false');
});

it('selects multiple rows independently', () => {
render(<Ledger />);
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));

fireEvent.click(rows[0].querySelector('td:nth-child(2)')!);
fireEvent.click(rows[1].querySelector('td:nth-child(2)')!);

expect(rows[0]).toHaveAttribute('aria-selected', 'true');
expect(rows[1]).toHaveAttribute('aria-selected', 'true');
});

it('shows selection count in header', () => {
render(<Ledger />);
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));

fireEvent.click(rows[0].querySelector('td:nth-child(2)')!);
fireEvent.click(rows[1].querySelector('td:nth-child(2)')!);

expect(screen.getByText('2 selected')).toBeInTheDocument();
});

it('selection indicator disappears when all deselected', () => {
render(<Ledger />);
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));

fireEvent.click(rows[0].querySelector('td:nth-child(2)')!);
expect(screen.getByText('1 selected')).toBeInTheDocument();

fireEvent.click(rows[0].querySelector('td:nth-child(2)')!);
expect(screen.queryByText('1 selected')).not.toBeInTheDocument();
});

it('selects all rows with Ctrl+A on the table', () => {
render(<Ledger />);
const table = screen.getByRole('grid', { name: /ledger entries/i });

fireEvent.keyDown(table, { key: 'a', ctrlKey: true });

const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));
rows.forEach(row => {
expect(row).toHaveAttribute('aria-selected', 'true');
});
expect(screen.getByText(/10 selected/)).toBeInTheDocument(); // page has 10 rows
});

it('clears selection with Escape key', () => {
render(<Ledger />);
const table = screen.getByRole('grid', { name: /ledger entries/i });
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));

fireEvent.click(rows[0].querySelector('td:nth-child(2)')!);
fireEvent.click(rows[1].querySelector('td:nth-child(2)')!);

fireEvent.keyDown(table, { key: 'Escape' });

rows.forEach(row => {
expect(row).toHaveAttribute('aria-selected', 'false');
});
});

it('copies selected rows as TSV on Ctrl+C', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });

render(<Ledger />);
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));
const table = screen.getByRole('grid', { name: /ledger entries/i });

fireEvent.click(rows[0].querySelector('td:nth-child(2)')!);
fireEvent.keyDown(table, { key: 'c', ctrlKey: true });

await vi.waitFor(() => {
expect(writeText).toHaveBeenCalledTimes(1);
});

const tsv = writeText.mock.calls[0][0] as string;
expect(tsv).toContain('ID\tDate\tType'); // header row
expect(tsv).toContain('ENT-0001'); // selected row data
});

it('does not copy when nothing is selected', () => {
const writeText = vi.fn();
Object.assign(navigator, { clipboard: { writeText } });

render(<Ledger />);
const table = screen.getByRole('grid', { name: /ledger entries/i });

fireEvent.keyDown(table, { key: 'c', ctrlKey: true });

expect(writeText).not.toHaveBeenCalled();
});

it('shows "Copied!" feedback after Ctrl+C', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });

render(<Ledger />);
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));
const table = screen.getByRole('grid', { name: /ledger entries/i });

fireEvent.click(rows[0].querySelector('td:nth-child(2)')!);
fireEvent.keyDown(table, { key: 'c', ctrlKey: true });

await vi.waitFor(() => {
expect(screen.getByText(/copied/i)).toBeInTheDocument();
});
});

it('supports Shift+click range selection', () => {
render(<Ledger />);
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));

// Click first row
fireEvent.click(rows[0].querySelector('td:nth-child(2)')!);
expect(rows[0]).toHaveAttribute('aria-selected', 'true');

// Shift+click third row
fireEvent.click(rows[2].querySelector('td:nth-child(2)')!, { shiftKey: true });

expect(rows[0]).toHaveAttribute('aria-selected', 'true');
expect(rows[1]).toHaveAttribute('aria-selected', 'true');
expect(rows[2]).toHaveAttribute('aria-selected', 'true');
});

it('shows selected rows with blue background', () => {
render(<Ledger />);
const rows = screen.getAllByRole('row').filter(r => r.hasAttribute('data-row-id'));

fireEvent.click(rows[0].querySelector('td:nth-child(2)')!);

expect(rows[0]).toHaveClass('bg-blue-50');
expect(rows[0]).toHaveClass('ring-blue-300');
});
});

});
173 changes: 169 additions & 4 deletions src/pages/Ledger.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useMemo, useRef, useCallback } from 'react';
import { ChevronRight } from 'lucide-react';
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { Copy, Check } from 'lucide-react';
import { ChevronRight, Copy, Check } from 'lucide-react';

// ────────────────────────────────────────
// Types
Expand Down Expand Up @@ -104,6 +105,10 @@ const TYPE_STYLES: Record<LedgerEntry['type'], string> = {
export const Ledger: React.FC = () => {
const [currentPage, setCurrentPage] = useState(1);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [copiedFeedback, setCopiedFeedback] = useState(false);
const anchorIndexRef = useRef<number | null>(null);
const tableRef = useRef<HTMLTableElement>(null);
const [liveMessage, setLiveMessage] = useState('');
const pageSize = 10;

Expand Down Expand Up @@ -144,6 +149,135 @@ export const Ledger: React.FC = () => {
[],
);

// ─── Keyboard row selection helpers ────────────────────────────────
const toggleSelectRow = useCallback((id: string, event?: React.MouseEvent | KeyboardEvent) => {
setSelectedRows(prev => {
const next = new Set(prev);
if (event?.shiftKey && anchorIndexRef.current !== null) {
// Shift+click: range select from anchor to clicked row
const clickedIndex = pageData.findIndex(r => r.id === id);
const start = Math.min(anchorIndexRef.current, clickedIndex);
const end = Math.max(anchorIndexRef.current, clickedIndex);
for (let i = start; i <= end; i++) {
next.add(pageData[i].id);
}
} else if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}

// Update anchor index for next Shift selection
const clickedIndex = pageData.findIndex(r => r.id === id);
if (clickedIndex !== -1) anchorIndexRef.current = clickedIndex;

return next;
});
}, [pageData]);

const selectAll = useCallback(() => {
setSelectedRows(new Set(pageData.map(r => r.id)));
}, [pageData]);

const clearSelection = useCallback(() => {
setSelectedRows(new Set());
anchorIndexRef.current = null;
}, []);

// Copy selected rows as TSV (Ctrl+C)
const copySelectedAsTSV = useCallback(async () => {
if (selectedRows.size === 0) return;
const headers = ['ID', 'Date', 'Type', 'Amount', 'Asset', 'Status', 'Reference'];
const rows = pageData
.filter(r => selectedRows.has(r.id))
.map(r => [r.id, r.date, r.type, `$${r.amount.toFixed(2)}`, r.asset, r.status, r.reference].join('\t'));
const tsv = [headers.join('\t'), ...rows].join('\n');

try {
await navigator.clipboard.writeText(tsv);
setCopiedFeedback(true);
window.setTimeout(() => setCopiedFeedback(false), 2000);
} catch {
// Clipboard API unavailable — fallback silently
}
}, [selectedRows, pageData]);

// Keyboard handler for row selection
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Ctrl+A: select all visible rows
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
selectAll();
return;
}

// Ctrl+C: copy selected rows as TSV
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
if (selectedRows.size > 0) {
e.preventDefault();
copySelectedAsTSV();
}
return;
}

// Escape: clear selection
if (e.key === 'Escape') {
clearSelection();
return;
}

// Arrow keys with Shift for range selection
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const currentFocus = document.activeElement;
const rows = tableRef.current?.querySelectorAll<HTMLTableRowElement>('tr[data-row-id]');
if (!rows || rows.length === 0) return;

let currentIdx = -1;
rows.forEach((row, i) => {
if (row.contains(currentFocus)) currentIdx = i;
});

const delta = e.key === 'ArrowDown' ? 1 : -1;
const newIdx = Math.max(0, Math.min(rows.length - 1, (currentIdx === -1 ? 0 : currentIdx) + delta));

if (e.shiftKey) {
// Shift+Arrow: extend selection
if (anchorIndexRef.current === null) anchorIndexRef.current = currentIdx === -1 ? 0 : currentIdx;
const start = Math.min(anchorIndexRef.current, newIdx);
const end = Math.max(anchorIndexRef.current, newIdx);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
const id = pageData[i]?.id;
if (id) newSelection.add(id);
}
setSelectedRows(newSelection);
} else {
// Arrow without Shift: move focus and clear selection unless Ctrl held
if (!e.ctrlKey && !e.metaKey) {
setSelectedRows(new Set());
anchorIndexRef.current = newIdx;
}
// Focus the new row
const targetRow = rows[newIdx];
const firstCell = targetRow?.querySelector<HTMLElement>('td button, td');
firstCell?.focus();
}
}
}, [selectedRows, pageData, selectAll, clearSelection, copySelectedAsTSV]);

// Global Ctrl+C listener for copying outside table focus
useEffect(() => {
const onGlobalCopy = (e: ClipboardEvent) => {
if (selectedRows.size > 0 && document.activeElement?.closest('[data-ledger-table]')) {
e.preventDefault();
copySelectedAsTSV();
}
};
document.addEventListener('copy', onGlobalCopy);
return () => document.removeEventListener('copy', onGlobalCopy);
}, [selectedRows, copySelectedAsTSV]);

// Page change doesn't reset expanded state — persistence across paging
// is intentional per the requirements.

Expand All @@ -167,13 +301,29 @@ export const Ledger: React.FC = () => {
</h1>
<p className="text-muted text-sm mt-1">
Detailed transaction history and ledger entries.
{selectedRows.size > 0 && (
<span className="ml-2 inline-flex items-center gap-1 text-xs">
<span className="bg-blue-100 text-blue-800 rounded px-1.5 py-0.5 font-medium">
{selectedRows.size} selected
</span>
<span className="text-gray-400">· Ctrl+C to copy as TSV</span>
{copiedFeedback && (
<span className="text-green-600 flex items-center gap-0.5">
<Check size={12} aria-hidden="true" /> Copied!
</span>
)}
</span>
)}
</p>
</div>

{/* Table container */}
<div className="bg-white rounded-lg shadow overflow-hidden border border-gray-200">
{/* Desktop table */}
<table
ref={tableRef}
onKeyDown={handleKeyDown}
data-ledger-table="true"
className="min-w-full divide-y divide-gray-200 hidden sm:table"
aria-label="Ledger entries table"
role="grid"
Expand Down Expand Up @@ -234,11 +384,26 @@ export const Ledger: React.FC = () => {
<React.Fragment key={row.id}>
{/* Parent row */}
<tr
className={`hover:bg-gray-50 transition-colors duration-150 ${
data-row-id={row.id}
className={`hover:bg-gray-50 transition-colors duration-150 cursor-pointer ${
isExpanded
? 'bg-blue-50/40 border-l-4 border-l-blue-400'
? 'border-l-4 border-l-blue-400'
: ''
} ${
selectedRows.has(row.id)
? 'bg-blue-50 ring-1 ring-inset ring-blue-300'
: isExpanded
? 'bg-blue-50/40'
: ''
}`}
tabIndex={0}
role="row"
aria-selected={selectedRows.has(row.id)}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('button') || target.tagName === 'BUTTON') return;
toggleSelectRow(row.id, e as unknown as React.MouseEvent);
}}
aria-expanded={
hasSubEvents || hasEmptySubEvents
? isExpanded
Expand Down