Skip to content

Commit d22613c

Browse files
committed
feat(csv-table): interactive grid with sort, selection and copy
1 parent 7b6f283 commit d22613c

7 files changed

Lines changed: 1312 additions & 73 deletions

File tree

‎e2e/stories-a11y.known.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
"primitives-avatar--orbit (dark): color-contrast-unmeasured": "The orbit ring is positioned over the initials and hollowed by a mask; hit-testing finds its whole box and the sampler takes a mask as opaque, so the ground under the initials cannot be read.",
55
"primitives-avatar--orbit (light): color-contrast-unmeasured": "The orbit ring is positioned over the initials and hollowed by a mask; hit-testing finds its whole box and the sampler takes a mask as opaque, so the ground under the initials cannot be read.",
66
"primitives-avatar--stack-with-status (dark): color-contrast-unmeasured": "The orbit ring is positioned over the initials and hollowed by a mask; hit-testing finds its whole box and the sampler takes a mask as opaque, so the ground under the initials cannot be read.",
7-
"primitives-avatar--stack-with-status (light): color-contrast-unmeasured": "The orbit ring is positioned over the initials and hollowed by a mask; hit-testing finds its whole box and the sampler takes a mask as opaque, so the ground under the initials cannot be read."
7+
"primitives-avatar--stack-with-status (light): color-contrast-unmeasured": "The orbit ring is positioned over the initials and hollowed by a mask; hit-testing finds its whole box and the sampler takes a mask as opaque, so the ground under the initials cannot be read.",
8+
"primitives-csv-table--selection (dark): color-contrast-unmeasured": "The selected range's outline is one box laid over its cells that paints only a border; hit-testing finds that box over the selected text and the sampler cannot follow a box that paints its edges alone, so the ground under the selected cells cannot be read.",
9+
"primitives-csv-table--selection (light): color-contrast-unmeasured": "The selected range's outline is one box laid over its cells that paints only a border; hit-testing finds that box over the selected text and the sampler cannot follow a box that paints its edges alone, so the ground under the selected cells cannot be read."
810
}

‎src/lib/csv-grid.ts‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* The data side of the interactive CSV grid: everything that walks every row
3+
* of the file, kept out of the component so it runs once per file, once per
4+
* sort change, or once per query, and never once per keystroke of navigation.
5+
*/
6+
import type { SortDirection } from "../ui/sort-header-button.js";
7+
import type { ParsedCsv } from "./csv.js";
8+
9+
/** Rows past this stay out of the DOM; search, sort and filters still cover every row. */
10+
export const CSV_GRID_ROW_CAP = 1000;
11+
/** A column with more distinct values than this is not a category worth a filter. */
12+
export const CSV_FILTER_MAX_VALUES = 12;
13+
/** A value longer than this is prose, not a category label. */
14+
export const CSV_FILTER_MAX_LENGTH = 24;
15+
/**
16+
* The filter value for "the cell is blank". A NUL never appears in a CSV a
17+
* person edits, and `FilterBar` reserves the empty string for "no filter".
18+
*/
19+
export const CSV_BLANK_FILTER = "\u0000";
20+
21+
export type CsvSort = {
22+
readonly column: number;
23+
readonly direction: SortDirection;
24+
};
25+
26+
export type CsvColumn = {
27+
/** Every non-blank cell parses as a number, and at least one is non-blank. */
28+
readonly numeric: boolean;
29+
/** The parsed value per row for a numeric column, `null` for a blank. */
30+
readonly numbers: readonly (number | null)[] | null;
31+
/** Distinct non-blank values, sorted, when the column reads as a category. */
32+
readonly choices: readonly string[] | null;
33+
readonly hasBlank: boolean;
34+
};
35+
36+
export type CsvSheet = {
37+
readonly headers: readonly string[];
38+
readonly rows: readonly (readonly string[])[];
39+
readonly columns: readonly CsvColumn[];
40+
/** Each row's cells, lowercased and joined, so a search is one `includes`. */
41+
readonly haystacks: readonly string[];
42+
};
43+
44+
const collator = new Intl.Collator(undefined, {
45+
numeric: true,
46+
sensitivity: "base",
47+
});
48+
49+
const NUMBER =
50+
/^([-+]?)[$€£¥]?([-+]?)((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[eE][-+]?\d+)?|\.\d+)%?$/;
51+
52+
/**
53+
* A cell's numeric value, reading it the way a spreadsheet would display it:
54+
* thousands separators, a currency mark on either side of the sign, a trailing
55+
* percent worth a hundredth, and accounting negatives written in parentheses.
56+
*/
57+
export function cellNumber(raw: string): number | null {
58+
let text = raw.trim();
59+
let negative = false;
60+
if (text.startsWith("(") && text.endsWith(")")) {
61+
negative = true;
62+
text = text.slice(1, -1).trim();
63+
}
64+
const match = NUMBER.exec(text);
65+
if (match === null) return null;
66+
const [, outerSign = "", innerSign = "", digits = ""] = match;
67+
const sign = outerSign || innerSign;
68+
if ((outerSign && innerSign) || (negative && sign)) return null;
69+
const value =
70+
Number(digits.replaceAll(",", "")) / (text.endsWith("%") ? 100 : 1);
71+
return negative || sign === "-" ? -value : value;
72+
}
73+
74+
const isBlank = (cell: string) => cell.trim() === "";
75+
76+
function describeColumn(rows: ParsedCsv["rows"], column: number): CsvColumn {
77+
let hasBlank = false;
78+
let numbers: (number | null)[] | null = [];
79+
let distinct: Set<string> | null = new Set();
80+
for (const row of rows) {
81+
const cell = row[column] ?? "";
82+
if (isBlank(cell)) {
83+
hasBlank = true;
84+
numbers?.push(null);
85+
continue;
86+
}
87+
const value = numbers === null ? null : cellNumber(cell);
88+
if (value === null) numbers = null;
89+
else numbers?.push(value);
90+
distinct?.add(cell);
91+
if (
92+
distinct !== null &&
93+
(distinct.size > CSV_FILTER_MAX_VALUES ||
94+
cell.length > CSV_FILTER_MAX_LENGTH)
95+
) {
96+
distinct = null;
97+
}
98+
if (numbers === null && distinct === null && hasBlank) break;
99+
}
100+
const numeric = numbers?.some((value) => value !== null) ?? false;
101+
const choices =
102+
distinct !== null && distinct.size >= 2 && distinct.size <= rows.length / 2
103+
? [...distinct].sort(collator.compare)
104+
: null;
105+
return { numeric, numbers: numeric ? numbers : null, choices, hasBlank };
106+
}
107+
108+
/**
109+
* Walks the file once for everything search, sort and filters need later,
110+
* over the first `columnCount` columns only: a match in a column that is not
111+
* on screen would show a row with no visible reason for being there.
112+
*/
113+
export function prepareSheet(parsed: ParsedCsv, columnCount: number): CsvSheet {
114+
return {
115+
headers: parsed.headers,
116+
rows: parsed.rows,
117+
columns: parsed.headers
118+
.slice(0, columnCount)
119+
.map((_, column) => describeColumn(parsed.rows, column)),
120+
haystacks: parsed.rows.map((row) =>
121+
row.slice(0, columnCount).join("\n").toLowerCase(),
122+
),
123+
};
124+
}
125+
126+
/**
127+
* Row indices in display order. Blanks go last in either direction, as a
128+
* spreadsheet places them, and ties keep file order.
129+
*/
130+
export function sortRows(sheet: CsvSheet, sort: CsvSort | null): number[] {
131+
const order = sheet.rows.map((_, index) => index);
132+
if (sort === null) return order;
133+
const sign = sort.direction === "asc" ? 1 : -1;
134+
const numbers = sheet.columns[sort.column]?.numbers ?? null;
135+
const cell = (index: number) => sheet.rows[index]?.[sort.column] ?? "";
136+
return order.sort((a, b) => {
137+
const left = cell(a);
138+
const right = cell(b);
139+
const blanks = Number(isBlank(left)) - Number(isBlank(right));
140+
if (blanks !== 0) return blanks;
141+
if (isBlank(left)) return 0;
142+
return (
143+
sign *
144+
(numbers === null
145+
? collator.compare(left, right)
146+
: (numbers[a] ?? 0) - (numbers[b] ?? 0))
147+
);
148+
});
149+
}
150+
151+
/** The rows of `order` that match the query and every column filter. */
152+
export function filterRows(
153+
sheet: CsvSheet,
154+
order: readonly number[],
155+
query: string,
156+
filters: ReadonlyMap<number, string>,
157+
): readonly number[] {
158+
const needle = query.trim().toLowerCase();
159+
if (needle === "" && filters.size === 0) return order;
160+
return order.filter((index) => {
161+
const row = sheet.rows[index] ?? [];
162+
for (const [column, value] of filters) {
163+
const cell = row[column] ?? "";
164+
if (value === CSV_BLANK_FILTER ? !isBlank(cell) : cell !== value) {
165+
return false;
166+
}
167+
}
168+
return needle === "" || (sheet.haystacks[index] ?? "").includes(needle);
169+
});
170+
}
171+
172+
const tsvCell = (cell: string) =>
173+
/[\t\n\r"]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell;
174+
175+
/** Tab-separated text, quoted where a spreadsheet's paste needs it. */
176+
export function toTsv(rows: readonly (readonly string[])[]): string {
177+
return rows.map((row) => row.map(tsvCell).join("\t")).join("\n");
178+
}

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

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { act, createElement } from "react";
3+
import type { ComponentProps } from "react";
4+
import { createRoot } from "react-dom/client";
5+
6+
import { CSV_GRID_ROW_CAP } from "../lib/csv-grid.js";
7+
import type { CsvSelection } from "./csv-table.js";
8+
import { CsvTable, csvFilterSpecs } from "./csv-table.js";
9+
10+
function mount(props: ComponentProps<typeof CsvTable>) {
11+
const container = document.createElement("div");
12+
document.body.appendChild(container);
13+
const root = createRoot(container);
14+
const render = (next: ComponentProps<typeof CsvTable>) =>
15+
act(() => {
16+
root.render(createElement(CsvTable, next));
17+
});
18+
render(props);
19+
return {
20+
container,
21+
render,
22+
firstColumn: () =>
23+
[...container.querySelectorAll('tbody [data-cell$=":0"]')].map(
24+
(cell) => cell.textContent,
25+
),
26+
status: () => container.querySelector('[role="status"]')?.textContent,
27+
unmount: () => {
28+
act(() => root.unmount());
29+
container.remove();
30+
},
31+
};
32+
}
33+
34+
const click = (element: Element | null, init: MouseEventInit = {}) =>
35+
act(() => {
36+
element?.dispatchEvent(new MouseEvent("click", { bubbles: true, ...init }));
37+
});
38+
39+
const header = (container: HTMLElement, name: string) =>
40+
[...container.querySelectorAll("thead button")].find(
41+
(button) => button.textContent === name,
42+
) ?? null;
43+
44+
describe("CsvTable interactive", () => {
45+
test("search, sort and select-all cover rows past the render cap", () => {
46+
const reported: (CsvSelection | null)[] = [];
47+
const rows = Array.from(
48+
{ length: CSV_GRID_ROW_CAP + 500 },
49+
(_, index) => `item-${index},${index}`,
50+
);
51+
const props = {
52+
text: ["name,amount", ...rows].join("\n"),
53+
interactive: true,
54+
onSelectionChange: (selection: CsvSelection | null) =>
55+
reported.push(selection),
56+
};
57+
const { container, render, firstColumn, status, unmount } = mount(props);
58+
expect(firstColumn()).toHaveLength(CSV_GRID_ROW_CAP);
59+
act(() => {
60+
container.querySelector("table")?.dispatchEvent(
61+
new KeyboardEvent("keydown", {
62+
key: "a",
63+
ctrlKey: true,
64+
bubbles: true,
65+
}),
66+
);
67+
});
68+
expect(reported.at(-1)?.rows).toBe(CSV_GRID_ROW_CAP + 500);
69+
70+
click(header(container, "amount"));
71+
click(header(container, "amount"));
72+
expect(firstColumn()[0]).toBe(`item-${CSV_GRID_ROW_CAP + 499}`);
73+
74+
render({ ...props, query: "ITEM-1499" });
75+
expect(firstColumn()).toEqual(["item-1499"]);
76+
expect(status()).toContain("1 of 1,500 rows");
77+
unmount();
78+
});
79+
80+
test("numbers sort as numbers, blanks last, and a blank filter matches blanks", () => {
81+
const text = 'name,amount,kind\na,"(1,234)",x\nb,,y\nc,$-5,x\nd,20,\ne,3,y';
82+
const { container, render, firstColumn, unmount } = mount({
83+
text,
84+
interactive: true,
85+
});
86+
click(header(container, "amount"));
87+
expect(firstColumn()).toEqual(["a", "c", "e", "d", "b"]);
88+
89+
const kind = csvFilterSpecs(text).find((spec) => spec.label === "kind");
90+
const blank = kind?.options.at(-1)?.value;
91+
expect(kind?.options.at(-1)?.label).toBe("(blank)");
92+
render({ text, interactive: true, filters: { [kind!.id]: blank! } });
93+
expect(firstColumn()).toEqual(["d"]);
94+
unmount();
95+
});
96+
97+
test("a selected range reports the sorted, filtered cells as TSV", () => {
98+
const reported: (CsvSelection | null)[] = [];
99+
const { container, unmount } = mount({
100+
text: "name,amount,note\nc,3,keep\na,1,keep\nb,2,drop\nd,4,keep",
101+
interactive: true,
102+
query: "keep",
103+
onSelectionChange: (selection) => reported.push(selection),
104+
});
105+
click(header(container, "name"));
106+
click(container.querySelector('[data-cell="0:0"]'));
107+
click(container.querySelector('[data-cell="1:1"]'), { shiftKey: true });
108+
expect(reported.at(-1)?.toTsv()).toBe("a\t1\nc\t3");
109+
unmount();
110+
});
111+
112+
test("a failed copy is reported until the selection moves", async () => {
113+
const original = Object.getOwnPropertyDescriptor(navigator, "clipboard");
114+
Object.defineProperty(navigator, "clipboard", {
115+
configurable: true,
116+
value: { writeText: () => Promise.reject(new Error("blocked")) },
117+
});
118+
const { container, unmount } = mount({
119+
text: "name,amount\na,1\nb,2",
120+
interactive: true,
121+
});
122+
const copyStatus = () =>
123+
container.querySelectorAll('[role="status"]')[1]?.textContent;
124+
try {
125+
click(container.querySelector('[data-cell="0:0"]'));
126+
await act(async () => {
127+
container.querySelector("table")?.dispatchEvent(
128+
new KeyboardEvent("keydown", {
129+
key: "c",
130+
ctrlKey: true,
131+
bubbles: true,
132+
}),
133+
);
134+
});
135+
expect(copyStatus()).toContain("Couldn't copy");
136+
click(container.querySelector('[data-cell="1:0"]'));
137+
expect(copyStatus()).toBe("");
138+
} finally {
139+
unmount();
140+
if (original === undefined) {
141+
Reflect.deleteProperty(navigator, "clipboard");
142+
} else {
143+
Object.defineProperty(navigator, "clipboard", original);
144+
}
145+
}
146+
});
147+
148+
test("new text resets sort and selection", () => {
149+
const selections: (CsvSelection | null)[] = [];
150+
const props = {
151+
interactive: true,
152+
onSelectionChange: (selection: CsvSelection | null) =>
153+
selections.push(selection),
154+
};
155+
const { container, render, firstColumn, unmount } = mount({
156+
...props,
157+
text: "name,amount\nb,2\na,1",
158+
});
159+
click(header(container, "name"));
160+
click(container.querySelector('[data-cell="0:0"]'));
161+
162+
render({ ...props, text: "name,amount\nz,9\ny,8" });
163+
expect(firstColumn()).toEqual(["z", "y"]);
164+
expect(container.querySelector("[aria-sort]")).toBeNull();
165+
expect(selections.at(-1)).toBeNull();
166+
unmount();
167+
});
168+
});

0 commit comments

Comments
 (0)