Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion e2e/stories-a11y.known.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
"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.",
"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.",
"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.",
"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."
"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.",
"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.",
"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."
}
178 changes: 178 additions & 0 deletions src/lib/csv-grid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* The data side of the interactive CSV grid: everything that walks every row
* of the file, kept out of the component so it runs once per file, once per
* sort change, or once per query, and never once per keystroke of navigation.
*/
import type { SortDirection } from "../ui/sort-header-button.js";
import type { ParsedCsv } from "./csv.js";

/** Rows past this stay out of the DOM; search, sort and filters still cover every row. */
export const CSV_GRID_ROW_CAP = 1000;
/** A column with more distinct values than this is not a category worth a filter. */
export const CSV_FILTER_MAX_VALUES = 12;
/** A value longer than this is prose, not a category label. */
export const CSV_FILTER_MAX_LENGTH = 24;
/**
* The filter value for "the cell is blank". A NUL never appears in a CSV a
* person edits, and `FilterBar` reserves the empty string for "no filter".
*/
export const CSV_BLANK_FILTER = "\u0000";

export type CsvSort = {
readonly column: number;
readonly direction: SortDirection;
};

export type CsvColumn = {
/** Every non-blank cell parses as a number, and at least one is non-blank. */
readonly numeric: boolean;
/** The parsed value per row for a numeric column, `null` for a blank. */
readonly numbers: readonly (number | null)[] | null;
/** Distinct non-blank values, sorted, when the column reads as a category. */
readonly choices: readonly string[] | null;
readonly hasBlank: boolean;
};

export type CsvSheet = {
readonly headers: readonly string[];
readonly rows: readonly (readonly string[])[];
readonly columns: readonly CsvColumn[];
/** Each row's cells, lowercased and joined, so a search is one `includes`. */
readonly haystacks: readonly string[];
};

const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});

const NUMBER =
/^([-+]?)[$€£¥]?([-+]?)((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[eE][-+]?\d+)?|\.\d+)%?$/;

/**
* A cell's numeric value, reading it the way a spreadsheet would display it:
* thousands separators, a currency mark on either side of the sign, a trailing
* percent worth a hundredth, and accounting negatives written in parentheses.
*/
export function cellNumber(raw: string): number | null {
let text = raw.trim();
let negative = false;
if (text.startsWith("(") && text.endsWith(")")) {
negative = true;
text = text.slice(1, -1).trim();
}
const match = NUMBER.exec(text);
if (match === null) return null;
const [, outerSign = "", innerSign = "", digits = ""] = match;
const sign = outerSign || innerSign;
if ((outerSign && innerSign) || (negative && sign)) return null;
const value =
Number(digits.replaceAll(",", "")) / (text.endsWith("%") ? 100 : 1);
return negative || sign === "-" ? -value : value;
}

const isBlank = (cell: string) => cell.trim() === "";

function describeColumn(rows: ParsedCsv["rows"], column: number): CsvColumn {
let hasBlank = false;
let numbers: (number | null)[] | null = [];
let distinct: Set<string> | null = new Set();
for (const row of rows) {
const cell = row[column] ?? "";
if (isBlank(cell)) {
hasBlank = true;
numbers?.push(null);
continue;
}
const value = numbers === null ? null : cellNumber(cell);
if (value === null) numbers = null;
else numbers?.push(value);
distinct?.add(cell);
if (
distinct !== null &&
(distinct.size > CSV_FILTER_MAX_VALUES ||
cell.length > CSV_FILTER_MAX_LENGTH)
) {
distinct = null;
}
if (numbers === null && distinct === null && hasBlank) break;
}
const numeric = numbers?.some((value) => value !== null) ?? false;
const choices =
distinct !== null && distinct.size >= 2 && distinct.size <= rows.length / 2
? [...distinct].sort(collator.compare)
: null;
return { numeric, numbers: numeric ? numbers : null, choices, hasBlank };
}

/**
* Walks the file once for everything search, sort and filters need later,
* over the first `columnCount` columns only: a match in a column that is not
* on screen would show a row with no visible reason for being there.
*/
export function prepareSheet(parsed: ParsedCsv, columnCount: number): CsvSheet {
return {
headers: parsed.headers,
rows: parsed.rows,
columns: parsed.headers
.slice(0, columnCount)
.map((_, column) => describeColumn(parsed.rows, column)),
haystacks: parsed.rows.map((row) =>
row.slice(0, columnCount).join("\n").toLowerCase(),
),
};
}

/**
* Row indices in display order. Blanks go last in either direction, as a
* spreadsheet places them, and ties keep file order.
*/
export function sortRows(sheet: CsvSheet, sort: CsvSort | null): number[] {
const order = sheet.rows.map((_, index) => index);
if (sort === null) return order;
const sign = sort.direction === "asc" ? 1 : -1;
const numbers = sheet.columns[sort.column]?.numbers ?? null;
const cell = (index: number) => sheet.rows[index]?.[sort.column] ?? "";
return order.sort((a, b) => {
const left = cell(a);
const right = cell(b);
const blanks = Number(isBlank(left)) - Number(isBlank(right));
if (blanks !== 0) return blanks;
if (isBlank(left)) return 0;
return (
sign *
(numbers === null
? collator.compare(left, right)
: (numbers[a] ?? 0) - (numbers[b] ?? 0))
);
});
}

/** The rows of `order` that match the query and every column filter. */
export function filterRows(
sheet: CsvSheet,
order: readonly number[],
query: string,
filters: ReadonlyMap<number, string>,
): readonly number[] {
const needle = query.trim().toLowerCase();
if (needle === "" && filters.size === 0) return order;
return order.filter((index) => {
const row = sheet.rows[index] ?? [];
for (const [column, value] of filters) {
const cell = row[column] ?? "";
if (value === CSV_BLANK_FILTER ? !isBlank(cell) : cell !== value) {
return false;
}
}
return needle === "" || (sheet.haystacks[index] ?? "").includes(needle);
});
}

const tsvCell = (cell: string) =>
/[\t\n\r"]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell;

/** Tab-separated text, quoted where a spreadsheet's paste needs it. */
export function toTsv(rows: readonly (readonly string[])[]): string {
return rows.map((row) => row.map(tsvCell).join("\t")).join("\n");
}
168 changes: 168 additions & 0 deletions src/ui/csv-table.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, expect, test } from "bun:test";
import { act, createElement } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";

import { CSV_GRID_ROW_CAP } from "../lib/csv-grid.js";
import type { CsvSelection } from "./csv-table.js";
import { CsvTable, csvFilterSpecs } from "./csv-table.js";

function mount(props: ComponentProps<typeof CsvTable>) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const render = (next: ComponentProps<typeof CsvTable>) =>
act(() => {
root.render(createElement(CsvTable, next));
});
render(props);
return {
container,
render,
firstColumn: () =>
[...container.querySelectorAll('tbody [data-cell$=":0"]')].map(
(cell) => cell.textContent,
),
status: () => container.querySelector('[role="status"]')?.textContent,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}

const click = (element: Element | null, init: MouseEventInit = {}) =>
act(() => {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true, ...init }));
});

const header = (container: HTMLElement, name: string) =>
[...container.querySelectorAll("thead button")].find(
(button) => button.textContent === name,
) ?? null;

describe("CsvTable interactive", () => {
test("search, sort and select-all cover rows past the render cap", () => {
const reported: (CsvSelection | null)[] = [];
const rows = Array.from(
{ length: CSV_GRID_ROW_CAP + 500 },
(_, index) => `item-${index},${index}`,
);
const props = {
text: ["name,amount", ...rows].join("\n"),
interactive: true,
onSelectionChange: (selection: CsvSelection | null) =>
reported.push(selection),
};
const { container, render, firstColumn, status, unmount } = mount(props);
expect(firstColumn()).toHaveLength(CSV_GRID_ROW_CAP);
act(() => {
container.querySelector("table")?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "a",
ctrlKey: true,
bubbles: true,
}),
);
});
expect(reported.at(-1)?.rows).toBe(CSV_GRID_ROW_CAP + 500);

click(header(container, "amount"));
click(header(container, "amount"));
expect(firstColumn()[0]).toBe(`item-${CSV_GRID_ROW_CAP + 499}`);

render({ ...props, query: "ITEM-1499" });
expect(firstColumn()).toEqual(["item-1499"]);
expect(status()).toContain("1 of 1,500 rows");
unmount();
});

test("numbers sort as numbers, blanks last, and a blank filter matches blanks", () => {
const text = 'name,amount,kind\na,"(1,234)",x\nb,,y\nc,$-5,x\nd,20,\ne,3,y';
const { container, render, firstColumn, unmount } = mount({
text,
interactive: true,
});
click(header(container, "amount"));
expect(firstColumn()).toEqual(["a", "c", "e", "d", "b"]);

const kind = csvFilterSpecs(text).find((spec) => spec.label === "kind");
const blank = kind?.options.at(-1)?.value;
expect(kind?.options.at(-1)?.label).toBe("(blank)");
render({ text, interactive: true, filters: { [kind!.id]: blank! } });
expect(firstColumn()).toEqual(["d"]);
unmount();
});

test("a selected range reports the sorted, filtered cells as TSV", () => {
const reported: (CsvSelection | null)[] = [];
const { container, unmount } = mount({
text: "name,amount,note\nc,3,keep\na,1,keep\nb,2,drop\nd,4,keep",
interactive: true,
query: "keep",
onSelectionChange: (selection) => reported.push(selection),
});
click(header(container, "name"));
click(container.querySelector('[data-cell="0:0"]'));
click(container.querySelector('[data-cell="1:1"]'), { shiftKey: true });
expect(reported.at(-1)?.toTsv()).toBe("a\t1\nc\t3");
unmount();
});

test("a failed copy is reported until the selection moves", async () => {
const original = Object.getOwnPropertyDescriptor(navigator, "clipboard");
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: () => Promise.reject(new Error("blocked")) },
});
const { container, unmount } = mount({
text: "name,amount\na,1\nb,2",
interactive: true,
});
const copyStatus = () =>
container.querySelectorAll('[role="status"]')[1]?.textContent;
try {
click(container.querySelector('[data-cell="0:0"]'));
await act(async () => {
container.querySelector("table")?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "c",
ctrlKey: true,
bubbles: true,
}),
);
});
expect(copyStatus()).toContain("Couldn't copy");
click(container.querySelector('[data-cell="1:0"]'));
expect(copyStatus()).toBe("");
} finally {
unmount();
if (original === undefined) {
Reflect.deleteProperty(navigator, "clipboard");
} else {
Object.defineProperty(navigator, "clipboard", original);
}
}
});

test("new text resets sort and selection", () => {
const selections: (CsvSelection | null)[] = [];
const props = {
interactive: true,
onSelectionChange: (selection: CsvSelection | null) =>
selections.push(selection),
};
const { container, render, firstColumn, unmount } = mount({
...props,
text: "name,amount\nb,2\na,1",
});
click(header(container, "name"));
click(container.querySelector('[data-cell="0:0"]'));

render({ ...props, text: "name,amount\nz,9\ny,8" });
expect(firstColumn()).toEqual(["z", "y"]);
expect(container.querySelector("[aria-sort]")).toBeNull();
expect(selections.at(-1)).toBeNull();
unmount();
});
});
Loading
Loading