diff --git a/src/lib/csv-grid.ts b/src/lib/csv-grid.ts index e630298..a724d5c 100644 --- a/src/lib/csv-grid.ts +++ b/src/lib/csv-grid.ts @@ -176,3 +176,12 @@ const tsvCell = (cell: string) => export function toTsv(rows: readonly (readonly string[])[]): string { return rows.map((row) => row.map(tsvCell).join("\t")).join("\n"); } + +/** A spreadsheet column letter for a zero-based index: 0 โ†’ A, 25 โ†’ Z, 26 โ†’ AA. */ +export function columnLetter(index: number): string { + let letters = ""; + for (let rest = index + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) { + letters = String.fromCharCode(65 + ((rest - 1) % 26)) + letters; + } + return letters; +} diff --git a/src/ui/csv-table.test.tsx b/src/ui/csv-table.test.tsx index a1765ea..065e03e 100644 --- a/src/ui/csv-table.test.tsx +++ b/src/ui/csv-table.test.tsx @@ -109,6 +109,22 @@ describe("CsvTable interactive", () => { unmount(); }); + test("rows number from the header as 1, a sort renumbers and a filter leaves gaps", () => { + const { container, unmount } = mount({ + text: "name,amount,note\nc,3,keep\na,1,keep\nb,2,drop\nd,4,keep", + interactive: true, + query: "keep", + }); + const numbers = () => + [...container.querySelectorAll('[data-cell$=":-1"]')].map( + (cell) => cell.textContent, + ); + expect(numbers()).toEqual(["1", "2", "3", "5"]); + click(header(container, "name")); + expect(numbers()).toEqual(["1", "2", "4", "5"]); + unmount(); + }); + test("a failed copy is reported until the selection moves", async () => { const original = Object.getOwnPropertyDescriptor(navigator, "clipboard"); Object.defineProperty(navigator, "clipboard", { diff --git a/src/ui/csv-table.tsx b/src/ui/csv-table.tsx index 6a5fc1f..6124d31 100644 --- a/src/ui/csv-table.tsx +++ b/src/ui/csv-table.tsx @@ -17,6 +17,7 @@ import { useCopy } from "../hooks/use-copy.js"; import { CSV_BLANK_FILTER, CSV_GRID_ROW_CAP, + columnLetter, filterRows, prepareSheet, sortRows, @@ -47,7 +48,7 @@ export type CsvTableLabels = { readonly anyValue: string; readonly blank: string; readonly untitledColumn: (position: number) => string; - readonly rowNumber: string; + readonly selectAll: string; readonly count: (rows: number, columns: number) => string; readonly filteredCount: ( matching: number, @@ -77,7 +78,7 @@ const LABELS: CsvTableLabels = { anyValue: "Any", blank: "(blank)", untitledColumn: (position) => `Column ${position.toLocaleString()}`, - rowNumber: "Row", + selectAll: "Select all", count: (rows, columns) => `${counted(rows, "row", "rows")} ยท ${counted(columns, "column", "columns")}`, filteredCount: (matching, total, columns) => @@ -298,7 +299,7 @@ function Notice({ children }: { readonly children: ReactNode }) { ); } -/** Row -1 is the CSV header row. */ +/** Row -1 is the CSV header row and column -1 the row numbers. */ type Cell = { readonly row: number; readonly column: number }; type Range = { readonly view: readonly number[]; @@ -335,6 +336,10 @@ const keepTextUnselected = (event: MouseEvent) => { * person sees, and it spans every matching row, including those past the * render cap. A selection remembers the view it was made in: once a new query, * filter or sort lands, it no longer applies. + * + * Rows are numbered as a spreadsheet numbers them: the header is row 1 and a + * sort renumbers, while a filter hides rows and leaves gaps, so a number is + * the row's place in the sorted, unfiltered sheet. */ function CsvGrid({ parsed, @@ -401,7 +406,7 @@ function CsvGrid({ const copyFailed = failedCopy !== null && failedCopy === selection; const at = { row: clamp(active.row, -1, lastShown), - column: clamp(active.column, 0, lastColumn), + column: clamp(active.column, -1, lastColumn), }; const [rowFrom, rowTo] = selection === null @@ -517,6 +522,35 @@ function CsvGrid({ [view], ); + const selectRow = useCallback( + (row: number, extend: boolean) => { + setActive({ row, column: -1 }); + setRange((current) => ({ + view, + anchor: { + row: extend && current?.view === view ? current.anchor.row : row, + column: 0, + }, + focus: { row, column: lastColumn }, + })); + }, + [view, lastColumn], + ); + + function selectColumn(column: number, extend: boolean) { + if (view.length === 0) return; + setActive({ row: 0, column }); + setRange((current) => ({ + view, + anchor: { + row: 0, + column: + extend && current?.view === view ? current.anchor.column : column, + }, + focus: { row: view.length - 1, column }, + })); + } + function selectAll() { if (view.length === 0) return; setRange({ @@ -529,19 +563,23 @@ function CsvGrid({ function moveTo(cell: Cell, extend: boolean) { const target = { row: clamp(cell.row, -1, lastShown), - column: clamp(cell.column, 0, lastColumn), + column: clamp(cell.column, -1, lastColumn), }; + const inBody = (cell: Cell) => cell.row >= 0 && cell.column >= 0; setActive(target); if (extend) { - const anchor = selection?.anchor ?? (at.row >= 0 ? at : null); + const anchor = selection?.anchor ?? (inBody(at) ? at : null); if (anchor !== null) { setRange({ view, anchor, - focus: { row: Math.max(0, target.row), column: target.column }, + focus: { + row: Math.max(0, target.row), + column: Math.max(0, target.column), + }, }); } - } else if (target.row >= 0) { + } else if (inBody(target)) { setRange({ view, anchor: target, focus: target }); } scroller.current @@ -579,11 +617,23 @@ function CsvGrid({ void copyRange(selection); return; } + if (command && key === " " && at.column >= 0) { + event.preventDefault(); + selectColumn(at.column, event.shiftKey); + return; + } if (key === "Escape" && selection !== null) { event.preventDefault(); setRange(null); return; } + if ((key === " " || key === "Enter") && at.row >= 0) { + if (at.column === -1 || (key === " " && event.shiftKey)) { + event.preventDefault(); + selectRow(at.row, at.column === -1 && event.shiftKey); + } + return; + } const arrow = ARROWS[key]; const edgeRow = (step: number) => (step < 0 ? 0 : lastShown); @@ -612,6 +662,9 @@ function CsvGrid({ moveTo(next, event.shiftKey); } + const rowLabel = (position: number) => + position === -1 ? 1 : (rank[view[position] ?? 0] ?? 0) + 2; + const notes = [ view.length === sheet.rows.length ? wording.count(sheet.rows.length, parsed.headers.length) @@ -638,7 +691,7 @@ function CsvGrid({ role="grid" aria-label={caption} aria-multiselectable="true" - aria-rowcount={view.length + 1} + aria-rowcount={view.length + 2} className="min-w-full border-separate border-spacing-0 [&_td]:max-w-80" onKeyDown={onKeyDown} onFocus={() => { @@ -654,9 +707,47 @@ function CsvGrid({ + + + {sheet.columns.map((_, index) => ( + selectColumn(index, event.shiftKey)} + className={cn( + "h-6 cursor-default border-r border-b border-border bg-card px-1 text-center font-normal text-muted-foreground", + index >= columnFrom && + index <= columnTo && + "bg-accent font-semibold text-accent-foreground shadow-[inset_0_-2px_0_var(--ring)]", + )} + > + {columnLetter(index)} + + ))} + + + setActive({ row: -1, column: -1 })} + className="sticky left-0 z-1 h-8 border-r border-b border-border bg-card px-2 text-right font-normal text-muted-foreground tabular-nums focus-visible:-outline-offset-2" > - {wording.rowNumber} + 1 {sheet.columns.map((column, index) => { const direction = @@ -697,13 +788,14 @@ function CsvGrid({ ); })} @@ -747,6 +839,7 @@ const GridRow = memo(function GridRow({ selectedTo, activeColumn, onSelectCell, + onSelectRow, }: { readonly position: number; readonly label: number; @@ -756,12 +849,21 @@ const GridRow = memo(function GridRow({ readonly selectedTo: number; readonly activeColumn: number; readonly onSelectCell: (cell: Cell, extend: boolean) => void; + readonly onSelectRow: (row: number, extend: boolean) => void; }) { return ( - + onSelectRow(position, event.shiftKey)} > {label.toLocaleString()}