Skip to content

Commit 53d5fe1

Browse files
committed
feat(csv-table): name box, cell contents and status-bar sums
1 parent e7c0c85 commit 53d5fe1

4 files changed

Lines changed: 147 additions & 8 deletions

File tree

‎src/lib/csv-grid.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,43 @@ export function columnLetter(index: number): string {
185185
}
186186
return letters;
187187
}
188+
189+
export type CsvRangeSummary = {
190+
/** Non-blank cells, as a spreadsheet's status bar counts them. */
191+
readonly count: number;
192+
/** Cells that read as a number, whatever their column. */
193+
readonly numbers: number;
194+
readonly sum: number;
195+
};
196+
197+
/**
198+
* Count and sum over view positions `rows` and columns `columns`, both
199+
* inclusive, as a spreadsheet's status bar does: every cell that reads as a
200+
* number adds to the sum. Numeric columns reuse the values parsed once per
201+
* file; only cells in the other columns are parsed here.
202+
*/
203+
export function summarizeRange(
204+
sheet: CsvSheet,
205+
view: readonly number[],
206+
rows: readonly [number, number],
207+
columns: readonly [number, number],
208+
): CsvRangeSummary {
209+
let count = 0;
210+
let numbers = 0;
211+
let sum = 0;
212+
for (const index of view.slice(rows[0], rows[1] + 1)) {
213+
const row = sheet.rows[index] ?? [];
214+
for (let column = columns[0]; column <= columns[1]; column += 1) {
215+
const cell = row[column] ?? "";
216+
if (isBlank(cell)) continue;
217+
count += 1;
218+
const parsed = sheet.columns[column]?.numbers;
219+
const value = parsed ? (parsed[index] ?? null) : cellNumber(cell);
220+
if (value !== null) {
221+
numbers += 1;
222+
sum += value;
223+
}
224+
}
225+
}
226+
return { count, numbers, sum };
227+
}

‎src/ui/csv-table.test.tsx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,28 @@ describe("CsvTable interactive", () => {
125125
unmount();
126126
});
127127

128+
test("the name box and status bar address and total the sorted, filtered range", () => {
129+
const { container, unmount } = mount({
130+
text: "name,amount,note\nc,3,keep\na,1,keep\nb,2,drop\nd,4,keep",
131+
interactive: true,
132+
query: "keep",
133+
});
134+
const nameBox = () =>
135+
container.querySelector<HTMLInputElement>("input[readonly]")?.value;
136+
const totals = () =>
137+
[...container.querySelectorAll("[data-total]")].map(
138+
(total) => total.textContent,
139+
);
140+
click(header(container, "name"));
141+
click(container.querySelector('[data-cell="0:1"]'));
142+
expect(nameBox()).toBe("B2");
143+
expect(container.querySelector("textarea")?.value).toBe("1");
144+
click(container.querySelector('[data-cell="2:1"]'), { shiftKey: true });
145+
expect(nameBox()).toBe("B2:B5");
146+
expect(totals()).toEqual(["Average: 2.667", "Count: 3", "Sum: 8"]);
147+
unmount();
148+
});
149+
128150
test("a failed copy is reported until the selection moves", async () => {
129151
const original = Object.getOwnPropertyDescriptor(navigator, "clipboard");
130152
Object.defineProperty(navigator, "clipboard", {

‎src/ui/csv-table.tsx‎

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
filterRows,
2222
prepareSheet,
2323
sortRows,
24+
summarizeRange,
2425
toTsv,
2526
} from "../lib/csv-grid.js";
2627
import type { CsvColumn, CsvSheet, CsvSort } from "../lib/csv-grid.js";
@@ -49,6 +50,8 @@ export type CsvTableLabels = {
4950
readonly blank: string;
5051
readonly untitledColumn: (position: number) => string;
5152
readonly selectAll: string;
53+
readonly nameBox: string;
54+
readonly cellContents: string;
5255
readonly count: (rows: number, columns: number) => string;
5356
readonly filteredCount: (
5457
matching: number,
@@ -59,6 +62,9 @@ export type CsvTableLabels = {
5962
readonly empty: string;
6063
readonly noMatches: string;
6164
readonly selected: (rows: number, columns: number) => string;
65+
readonly average: (value: number) => string;
66+
readonly cellCount: (cells: number) => string;
67+
readonly sum: (value: number) => string;
6268
readonly copied: string;
6369
readonly copyFailed: string;
6470
};
@@ -79,6 +85,8 @@ const LABELS: CsvTableLabels = {
7985
blank: "(blank)",
8086
untitledColumn: (position) => `Column ${position.toLocaleString()}`,
8187
selectAll: "Select all",
88+
nameBox: "Selected cell",
89+
cellContents: "Cell contents",
8290
count: (rows, columns) =>
8391
`${counted(rows, "row", "rows")} · ${counted(columns, "column", "columns")}`,
8492
filteredCount: (matching, total, columns) =>
@@ -88,6 +96,9 @@ const LABELS: CsvTableLabels = {
8896
noMatches: "No rows match.",
8997
selected: (rows, columns) =>
9098
`${counted(rows, "row", "rows")} × ${counted(columns, "column", "columns")} selected`,
99+
average: (value) => `Average: ${value.toLocaleString()}`,
100+
cellCount: (cells) => `Count: ${cells.toLocaleString()}`,
101+
sum: (value) => `Sum: ${value.toLocaleString()}`,
91102
copied: "Copied",
92103
copyFailed: "Couldn't copy. Your browser blocked the clipboard.",
93104
};
@@ -305,6 +316,8 @@ type Range = {
305316
readonly view: readonly number[];
306317
readonly anchor: Cell;
307318
readonly focus: Cell;
319+
/** Picked by row numbers or column letters, and addressed as such: `3:5`, `B:D`. */
320+
readonly whole?: "rows" | "columns";
308321
};
309322

310323
const clamp = (value: number, min: number, max: number) =>
@@ -339,7 +352,8 @@ const keepTextUnselected = (event: MouseEvent) => {
339352
*
340353
* Rows are numbered as a spreadsheet numbers them: the header is row 1 and a
341354
* sort renumbers, while a filter hides rows and leaves gaps, so a number is
342-
* the row's place in the sorted, unfiltered sheet.
355+
* the row's place in the sorted, unfiltered sheet and an address names the
356+
* same rectangle the person sees.
343357
*/
344358
function CsvGrid({
345359
parsed,
@@ -430,6 +444,14 @@ function CsvGrid({
430444
[sheet, view],
431445
);
432446

447+
const summary = useMemo(
448+
() =>
449+
rowFrom === -1
450+
? null
451+
: summarizeRange(sheet, view, [rowFrom, rowTo], [columnFrom, columnTo]),
452+
[sheet, view, rowFrom, rowTo, columnFrom, columnTo],
453+
);
454+
433455
const reportRef = useRef(onSelectionChange);
434456
reportRef.current = onSelectionChange;
435457
const reported = useRef<Range | null>(null);
@@ -532,6 +554,7 @@ function CsvGrid({
532554
column: 0,
533555
},
534556
focus: { row, column: lastColumn },
557+
whole: "rows",
535558
}));
536559
},
537560
[view, lastColumn],
@@ -548,6 +571,7 @@ function CsvGrid({
548571
extend && current?.view === view ? current.anchor.column : column,
549572
},
550573
focus: { row: view.length - 1, column },
574+
whole: "columns",
551575
}));
552576
}
553577

@@ -664,6 +688,26 @@ function CsvGrid({
664688

665689
const rowLabel = (position: number) =>
666690
position === -1 ? 1 : (rank[view[position] ?? 0] ?? 0) + 2;
691+
const address = (cell: Cell) =>
692+
`${columnLetter(cell.column)}${rowLabel(cell.row)}`;
693+
const nameBox =
694+
selection === null
695+
? at.column >= 0
696+
? address(at)
697+
: ""
698+
: selection.whole === "rows"
699+
? `${rowLabel(rowFrom)}:${rowLabel(rowTo)}`
700+
: selection.whole === "columns"
701+
? `${columnLetter(columnFrom)}:${columnLetter(columnTo)}`
702+
: rowFrom === rowTo && columnFrom === columnTo
703+
? address({ row: rowFrom, column: columnFrom })
704+
: `${address({ row: rowFrom, column: columnFrom })}:${address({ row: rowTo, column: columnTo })}`;
705+
const contents =
706+
at.column < 0
707+
? ""
708+
: at.row === -1
709+
? (sheet.headers[at.column] ?? "")
710+
: (sheet.rows[view[at.row] ?? -1]?.[at.column] ?? "");
667711

668712
const notes = [
669713
view.length === sheet.rows.length
@@ -678,14 +722,36 @@ function CsvGrid({
678722
? [wording.columnsOf(columnCount, parsed.headers.length)]
679723
: []),
680724
];
725+
const totals =
726+
summary === null
727+
? []
728+
: [
729+
...(summary.numbers > 0
730+
? [wording.average(summary.sum / summary.numbers)]
731+
: []),
732+
wording.cellCount(summary.count),
733+
...(summary.numbers > 0 ? [wording.sum(summary.sum)] : []),
734+
];
681735

682736
return (
683737
<div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
684738
<div className="flex min-h-0 min-w-0 flex-1 flex-col rounded-md border border-border bg-background text-xs">
685-
<div
686-
ref={scroller}
687-
className="min-h-0 flex-[1_1_32rem] overflow-auto rounded-t-md"
688-
>
739+
<div className="flex items-stretch overflow-hidden rounded-t-md border-b border-border">
740+
<input
741+
readOnly
742+
value={nameBox}
743+
aria-label={wording.nameBox}
744+
className="h-8 w-24 shrink-0 border-r border-border bg-background px-2 tabular-nums focus-visible:-outline-offset-2"
745+
/>
746+
<textarea
747+
readOnly
748+
rows={1}
749+
value={contents}
750+
aria-label={wording.cellContents}
751+
className="field-sizing-content max-h-20 min-h-8 min-w-0 flex-1 resize-none bg-background px-2 py-2 leading-4 break-words focus-visible:-outline-offset-2"
752+
/>
753+
</div>
754+
<div ref={scroller} className="min-h-0 flex-[1_1_32rem] overflow-auto">
689755
<div className="relative isolate w-max min-w-full">
690756
<table
691757
role="grid"
@@ -819,9 +885,19 @@ function CsvGrid({
819885
<p role="status" className={cn(copyFailed && "text-destructive")}>
820886
{copyFailed ? wording.copyFailed : copied ? wording.copied : ""}
821887
</p>
822-
{selection === null ? null : (
823-
<p className="ms-auto tabular-nums">
824-
{wording.selected(rowTo - rowFrom + 1, columnTo - columnFrom + 1)}
888+
{summary === null ? null : (
889+
<p className="ms-auto flex flex-wrap gap-x-4 tabular-nums">
890+
<span className="sr-only">
891+
{wording.selected(
892+
rowTo - rowFrom + 1,
893+
columnTo - columnFrom + 1,
894+
)}
895+
</span>
896+
{totals.map((total) => (
897+
<span key={total} data-total>
898+
{total}
899+
</span>
900+
))}
825901
</p>
826902
)}
827903
</div>

‎stories/primitives/csv-table.stories.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export const InteractiveRelabelled = () => (
146146
labels={{
147147
count: (rows, columns) =>
148148
`${rows.toLocaleString()} records, ${columns.toLocaleString()} fields`,
149+
sum: (value) => `Total ${value.toLocaleString()}`,
149150
}}
150151
/>
151152
);

0 commit comments

Comments
 (0)