diff --git a/src/pages/Ledger.test.tsx b/src/pages/Ledger.test.tsx index d96f073..3763c6f 100644 --- a/src/pages/Ledger.test.tsx +++ b/src/pages/Ledger.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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'); + }); + }); + }); diff --git a/src/pages/Ledger.tsx b/src/pages/Ledger.tsx index 987ebc2..5cd0c4b 100644 --- a/src/pages/Ledger.tsx +++ b/src/pages/Ledger.tsx @@ -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 @@ -104,6 +105,10 @@ const TYPE_STYLES: Record = { export const Ledger: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); const [expandedRows, setExpandedRows] = useState>(new Set()); + const [selectedRows, setSelectedRows] = useState>(new Set()); + const [copiedFeedback, setCopiedFeedback] = useState(false); + const anchorIndexRef = useRef(null); + const tableRef = useRef(null); const [liveMessage, setLiveMessage] = useState(''); const pageSize = 10; @@ -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('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(); + 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('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. @@ -167,6 +301,19 @@ export const Ledger: React.FC = () => {

Detailed transaction history and ledger entries. + {selectedRows.size > 0 && ( + + + {selectedRows.size} selected + + · Ctrl+C to copy as TSV + {copiedFeedback && ( + + + )} + + )}

@@ -174,6 +321,9 @@ export const Ledger: React.FC = () => {
{/* Desktop table */} { {/* Parent row */} { + 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