From 53d5fe16de61ef8f609cfcc73da37ef4d7467d13 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 31 Oct 2026 17:08:35 -0700 Subject: [PATCH] feat(csv-table): name box, cell contents and status-bar sums --- src/lib/csv-grid.ts | 40 +++++++++++ src/ui/csv-table.test.tsx | 22 ++++++ src/ui/csv-table.tsx | 92 +++++++++++++++++++++--- stories/primitives/csv-table.stories.tsx | 1 + 4 files changed, 147 insertions(+), 8 deletions(-) diff --git a/src/lib/csv-grid.ts b/src/lib/csv-grid.ts index a724d5c0..66f23549 100644 --- a/src/lib/csv-grid.ts +++ b/src/lib/csv-grid.ts @@ -185,3 +185,43 @@ export function columnLetter(index: number): string { } return letters; } + +export type CsvRangeSummary = { + /** Non-blank cells, as a spreadsheet's status bar counts them. */ + readonly count: number; + /** Cells that read as a number, whatever their column. */ + readonly numbers: number; + readonly sum: number; +}; + +/** + * Count and sum over view positions `rows` and columns `columns`, both + * inclusive, as a spreadsheet's status bar does: every cell that reads as a + * number adds to the sum. Numeric columns reuse the values parsed once per + * file; only cells in the other columns are parsed here. + */ +export function summarizeRange( + sheet: CsvSheet, + view: readonly number[], + rows: readonly [number, number], + columns: readonly [number, number], +): CsvRangeSummary { + let count = 0; + let numbers = 0; + let sum = 0; + for (const index of view.slice(rows[0], rows[1] + 1)) { + const row = sheet.rows[index] ?? []; + for (let column = columns[0]; column <= columns[1]; column += 1) { + const cell = row[column] ?? ""; + if (isBlank(cell)) continue; + count += 1; + const parsed = sheet.columns[column]?.numbers; + const value = parsed ? (parsed[index] ?? null) : cellNumber(cell); + if (value !== null) { + numbers += 1; + sum += value; + } + } + } + return { count, numbers, sum }; +} diff --git a/src/ui/csv-table.test.tsx b/src/ui/csv-table.test.tsx index 065e03ef..907141b7 100644 --- a/src/ui/csv-table.test.tsx +++ b/src/ui/csv-table.test.tsx @@ -125,6 +125,28 @@ describe("CsvTable interactive", () => { unmount(); }); + test("the name box and status bar address and total the sorted, filtered range", () => { + 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 nameBox = () => + container.querySelector("input[readonly]")?.value; + const totals = () => + [...container.querySelectorAll("[data-total]")].map( + (total) => total.textContent, + ); + click(header(container, "name")); + click(container.querySelector('[data-cell="0:1"]')); + expect(nameBox()).toBe("B2"); + expect(container.querySelector("textarea")?.value).toBe("1"); + click(container.querySelector('[data-cell="2:1"]'), { shiftKey: true }); + expect(nameBox()).toBe("B2:B5"); + expect(totals()).toEqual(["Average: 2.667", "Count: 3", "Sum: 8"]); + 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 6124d313..1bca0112 100644 --- a/src/ui/csv-table.tsx +++ b/src/ui/csv-table.tsx @@ -21,6 +21,7 @@ import { filterRows, prepareSheet, sortRows, + summarizeRange, toTsv, } from "../lib/csv-grid.js"; import type { CsvColumn, CsvSheet, CsvSort } from "../lib/csv-grid.js"; @@ -49,6 +50,8 @@ export type CsvTableLabels = { readonly blank: string; readonly untitledColumn: (position: number) => string; readonly selectAll: string; + readonly nameBox: string; + readonly cellContents: string; readonly count: (rows: number, columns: number) => string; readonly filteredCount: ( matching: number, @@ -59,6 +62,9 @@ export type CsvTableLabels = { readonly empty: string; readonly noMatches: string; readonly selected: (rows: number, columns: number) => string; + readonly average: (value: number) => string; + readonly cellCount: (cells: number) => string; + readonly sum: (value: number) => string; readonly copied: string; readonly copyFailed: string; }; @@ -79,6 +85,8 @@ const LABELS: CsvTableLabels = { blank: "(blank)", untitledColumn: (position) => `Column ${position.toLocaleString()}`, selectAll: "Select all", + nameBox: "Selected cell", + cellContents: "Cell contents", count: (rows, columns) => `${counted(rows, "row", "rows")} · ${counted(columns, "column", "columns")}`, filteredCount: (matching, total, columns) => @@ -88,6 +96,9 @@ const LABELS: CsvTableLabels = { noMatches: "No rows match.", selected: (rows, columns) => `${counted(rows, "row", "rows")} × ${counted(columns, "column", "columns")} selected`, + average: (value) => `Average: ${value.toLocaleString()}`, + cellCount: (cells) => `Count: ${cells.toLocaleString()}`, + sum: (value) => `Sum: ${value.toLocaleString()}`, copied: "Copied", copyFailed: "Couldn't copy. Your browser blocked the clipboard.", }; @@ -305,6 +316,8 @@ type Range = { readonly view: readonly number[]; readonly anchor: Cell; readonly focus: Cell; + /** Picked by row numbers or column letters, and addressed as such: `3:5`, `B:D`. */ + readonly whole?: "rows" | "columns"; }; const clamp = (value: number, min: number, max: number) => @@ -339,7 +352,8 @@ const keepTextUnselected = (event: MouseEvent) => { * * 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. + * the row's place in the sorted, unfiltered sheet and an address names the + * same rectangle the person sees. */ function CsvGrid({ parsed, @@ -430,6 +444,14 @@ function CsvGrid({ [sheet, view], ); + const summary = useMemo( + () => + rowFrom === -1 + ? null + : summarizeRange(sheet, view, [rowFrom, rowTo], [columnFrom, columnTo]), + [sheet, view, rowFrom, rowTo, columnFrom, columnTo], + ); + const reportRef = useRef(onSelectionChange); reportRef.current = onSelectionChange; const reported = useRef(null); @@ -532,6 +554,7 @@ function CsvGrid({ column: 0, }, focus: { row, column: lastColumn }, + whole: "rows", })); }, [view, lastColumn], @@ -548,6 +571,7 @@ function CsvGrid({ extend && current?.view === view ? current.anchor.column : column, }, focus: { row: view.length - 1, column }, + whole: "columns", })); } @@ -664,6 +688,26 @@ function CsvGrid({ const rowLabel = (position: number) => position === -1 ? 1 : (rank[view[position] ?? 0] ?? 0) + 2; + const address = (cell: Cell) => + `${columnLetter(cell.column)}${rowLabel(cell.row)}`; + const nameBox = + selection === null + ? at.column >= 0 + ? address(at) + : "" + : selection.whole === "rows" + ? `${rowLabel(rowFrom)}:${rowLabel(rowTo)}` + : selection.whole === "columns" + ? `${columnLetter(columnFrom)}:${columnLetter(columnTo)}` + : rowFrom === rowTo && columnFrom === columnTo + ? address({ row: rowFrom, column: columnFrom }) + : `${address({ row: rowFrom, column: columnFrom })}:${address({ row: rowTo, column: columnTo })}`; + const contents = + at.column < 0 + ? "" + : at.row === -1 + ? (sheet.headers[at.column] ?? "") + : (sheet.rows[view[at.row] ?? -1]?.[at.column] ?? ""); const notes = [ view.length === sheet.rows.length @@ -678,14 +722,36 @@ function CsvGrid({ ? [wording.columnsOf(columnCount, parsed.headers.length)] : []), ]; + const totals = + summary === null + ? [] + : [ + ...(summary.numbers > 0 + ? [wording.average(summary.sum / summary.numbers)] + : []), + wording.cellCount(summary.count), + ...(summary.numbers > 0 ? [wording.sum(summary.sum)] : []), + ]; return (
-
+
+ +