diff --git a/e2e/stories-a11y.known.json b/e2e/stories-a11y.known.json index 66ac273d..37bdf379 100644 --- a/e2e/stories-a11y.known.json +++ b/e2e/stories-a11y.known.json @@ -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." } diff --git a/src/lib/csv-grid.ts b/src/lib/csv-grid.ts new file mode 100644 index 00000000..e6302987 --- /dev/null +++ b/src/lib/csv-grid.ts @@ -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 | 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, +): 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"); +} diff --git a/src/ui/csv-table.test.tsx b/src/ui/csv-table.test.tsx new file mode 100644 index 00000000..a1765ea1 --- /dev/null +++ b/src/ui/csv-table.test.tsx @@ -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) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const render = (next: ComponentProps) => + 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(); + }); +}); diff --git a/src/ui/csv-table.tsx b/src/ui/csv-table.tsx index 30c106fc..6a5fc1fd 100644 --- a/src/ui/csv-table.tsx +++ b/src/ui/csv-table.tsx @@ -1,8 +1,33 @@ "use client"; -import { useMemo } from "react"; +import { + memo, + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; -import { capCsv, isTabular, parseCsv } from "../lib/csv.js"; +import { useControllableState } from "../hooks/use-controllable-state.js"; +import { useCopy } from "../hooks/use-copy.js"; +import { + CSV_BLANK_FILTER, + CSV_GRID_ROW_CAP, + filterRows, + prepareSheet, + sortRows, + toTsv, +} from "../lib/csv-grid.js"; +import type { CsvColumn, CsvSheet, CsvSort } from "../lib/csv-grid.js"; +import { CSV_COLUMN_CAP, capCsv, isTabular, parseCsv } from "../lib/csv.js"; +import type { ParsedCsv } from "../lib/csv.js"; +import { cn } from "../lib/utils.js"; +import type { FilterSpec } from "./filter-bar.js"; +import { SortHeaderButton, ariaSort } from "./sort-header-button.js"; import { Table, TableBody, @@ -12,6 +37,151 @@ import { TableRow, } from "./table.js"; +export type { CsvSort } from "../lib/csv-grid.js"; + +export type CsvTableLabels = { + readonly tooLarge: string; + readonly partial: (notes: readonly string[]) => string; + readonly rowsOf: (shown: number, total: number) => string; + readonly columnsOf: (shown: number, total: number) => string; + readonly anyValue: string; + readonly blank: string; + readonly untitledColumn: (position: number) => string; + readonly rowNumber: string; + readonly count: (rows: number, columns: number) => string; + readonly filteredCount: ( + matching: number, + total: number, + columns: number, + ) => string; + readonly rendered: (shown: number) => string; + readonly empty: string; + readonly noMatches: string; + readonly selected: (rows: number, columns: number) => string; + readonly copied: string; + readonly copyFailed: string; +}; + +const counted = (count: number, one: string, many: string) => + `${count.toLocaleString()} ${count === 1 ? one : many}`; + +const LABELS: CsvTableLabels = { + tooLarge: + "This file is too large to preview — download it to see the whole thing.", + partial: (notes) => + `Showing ${notes.join(" and ")} — download for the full file.`, + rowsOf: (shown, total) => + `${shown.toLocaleString()} of ${total.toLocaleString()} rows`, + columnsOf: (shown, total) => + `${shown.toLocaleString()} of ${total.toLocaleString()} columns`, + anyValue: "Any", + blank: "(blank)", + untitledColumn: (position) => `Column ${position.toLocaleString()}`, + rowNumber: "Row", + count: (rows, columns) => + `${counted(rows, "row", "rows")} · ${counted(columns, "column", "columns")}`, + filteredCount: (matching, total, columns) => + `${matching.toLocaleString()} of ${counted(total, "row", "rows")} · ${counted(columns, "column", "columns")}`, + rendered: (shown) => `showing the first ${shown.toLocaleString()}`, + empty: "This file has no rows.", + noMatches: "No rows match.", + selected: (rows, columns) => + `${counted(rows, "row", "rows")} × ${counted(columns, "column", "columns")} selected`, + copied: "Copied", + copyFailed: "Couldn't copy. Your browser blocked the clipboard.", +}; + +/** + * A rectangular selection of every matching row it spans, rendered or not. + * `toTsv` builds the text on demand: a select-all over a large file is a + * string the host may never ask for, and a host that owns the copy action + * copies this. + */ +export type CsvSelection = { + readonly rows: number; + readonly columns: number; + readonly toTsv: () => string; +}; + +/** Column filters keyed by the column's zero-based index, as `FilterSpec.id`. */ +export type CsvFilters = Readonly>; + +export type CsvTableProps = { + readonly text: string; + readonly caption?: string | undefined; + /** + * A compact spreadsheet grid that sorts, selects and copies, and applies the + * host's `query` and `filters`. Off, the table is the plain read-only + * preview. + */ + readonly interactive?: boolean | undefined; + /** + * The search query. The grid has no search, filter or copy controls of its + * own: the host renders them, from `csvFilterSpecs` and the selection's + * `toTsv`, and drives `query`, `filters` and `sort`. + */ + readonly query?: string | undefined; + readonly filters?: CsvFilters | undefined; + readonly sort?: CsvSort | null | undefined; + readonly onSortChange?: ((sort: CsvSort | null) => void) | undefined; + readonly labels?: Partial | undefined; + readonly onSelectionChange?: + | ((selection: CsvSelection | null) => void) + | undefined; +}; + +const NO_FILTERS: CsvFilters = {}; + +const columnTitle = ( + sheet: CsvSheet, + column: number, + wording: CsvTableLabels, +) => sheet.headers[column] || wording.untitledColumn(column + 1); + +function filterSpecsOf( + sheet: CsvSheet, + filters: CsvFilters, + wording: CsvTableLabels, +): FilterSpec[] { + return sheet.columns.flatMap((column, index) => + column.choices === null + ? [] + : [ + { + id: String(index), + label: columnTitle(sheet, index, wording), + options: [ + ...column.choices.map((value) => ({ value, label: value })), + ...(column.hasBlank + ? [{ value: CSV_BLANK_FILTER, label: wording.blank }] + : []), + ], + value: filters[String(index)] ?? null, + anyLabel: wording.anyValue, + }, + ], + ); +} + +/** + * The filters `CsvTable` offers for `text`, for a host that renders them in + * its own bar: pass them to `FilterBar`, and its changes back as `filters`. + * Parses the text, so memoise it on the text and the filters. + */ +export function csvFilterSpecs( + text: string, + filters: CsvFilters = NO_FILTERS, + labels?: Partial, +): FilterSpec[] { + const parsed = parseCsv(text); + if (parsed === null || !isTabular(parsed)) return []; + const sheet = prepareSheet( + parsed, + Math.min(parsed.headers.length, CSV_COLUMN_CAP), + ); + return filterSpecsOf(sheet, filters, { ...LABELS, ...labels }); +} + /** * CSV text, rendered as a table when that is honest and as raw text when it is * not. @@ -33,18 +203,15 @@ import { export function CsvTable({ text, caption = "CSV contents", -}: { - readonly text: string; - readonly caption?: string; -}) { + interactive = false, + labels, + ...grid +}: CsvTableProps) { const parsed = useMemo(() => parseCsv(text), [text]); + const wording = { ...LABELS, ...labels }; if (parsed === null) { - return ( -

- This file is too large to preview — download it to see the whole thing. -

- ); + return {wording.tooLarge}; } if (!isTabular(parsed)) { @@ -57,50 +224,569 @@ export function CsvTable({ ); } + if (interactive) { + // Keyed on the text so a new file never inherits the old one's + // selection or, unless the host owns them, sort, filters and query. + return ( + + ); + } + const capped = capCsv(parsed); const notes: string[] = []; if (capped.rowsTruncated) { - notes.push( - `${capped.rows.length.toLocaleString()} of ${capped.totalRows.toLocaleString()} rows`, - ); + notes.push(wording.rowsOf(capped.rows.length, capped.totalRows)); } if (capped.columnsTruncated) { - notes.push( - `${capped.headers.length.toLocaleString()} of ${capped.totalColumns.toLocaleString()} columns`, - ); + notes.push(wording.columnsOf(capped.headers.length, capped.totalColumns)); } return (
-
- - - - {capped.headers.map((header, index) => ( - - {header} - - ))} - - - - {capped.rows.map((row, rowIndex) => ( - - {capped.headers.map((_, cellIndex) => ( - - {row[cellIndex] ?? ""} - +
+ {/* Focusable so the keyboard can scroll it. */} +
+
+ + + {capped.headers.map((header, index) => ( + + {header} + ))} - ))} - -
+ + + {capped.rows.map((row, rowIndex) => ( + + {capped.headers.map((_, cellIndex) => ( + + {row[cellIndex] ?? ""} + + ))} + + ))} + + +
{notes.length === 0 ? null : (

- Showing {notes.join(" and ")} — download for the full file. + {wording.partial(notes)}

)} ); } + +function Notice({ children }: { readonly children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** Row -1 is the CSV header row. */ +type Cell = { readonly row: number; readonly column: number }; +type Range = { + readonly view: readonly number[]; + readonly anchor: Cell; + readonly focus: Cell; +}; + +const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)); + +const spanOf = (a: number, b: number) => + [Math.min(a, b), Math.max(a, b)] as const; + +const nextSort = (sort: CsvSort | null, column: number): CsvSort | null => + sort?.column !== column + ? { column, direction: "asc" } + : sort.direction === "asc" + ? { column, direction: "desc" } + : null; + +const ARROWS: Record = { + ArrowUp: { row: -1, column: 0 }, + ArrowDown: { row: 1, column: 0 }, + ArrowLeft: { row: 0, column: -1 }, + ArrowRight: { row: 0, column: 1 }, +}; + +const keepTextUnselected = (event: MouseEvent) => { + if (event.shiftKey) event.preventDefault(); +}; + +/** + * Selection positions index the sorted, filtered view, so a copy is what the + * 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. + */ +function CsvGrid({ + parsed, + caption, + wording, + query = "", + filters = NO_FILTERS, + sort: sortProp, + onSortChange, + onSelectionChange, +}: Omit & { + readonly parsed: ParsedCsv; + readonly caption: string; + readonly wording: CsvTableLabels; +}) { + const columnCount = Math.min(parsed.headers.length, CSV_COLUMN_CAP); + const lastColumn = columnCount - 1; + const sheet = useMemo( + () => prepareSheet(parsed, columnCount), + [parsed, columnCount], + ); + const [sort, setSort] = useControllableState({ + value: sortProp, + defaultValue: null, + onChange: onSortChange, + name: "CsvTable sort", + }); + const [range, setRange] = useState(null); + const [active, setActive] = useState({ row: -1, column: 0 }); + const [failedCopy, setFailedCopy] = useState(null); + const { copied, copy } = useCopy(); + const deferredQuery = useDeferredValue(query); + const scroller = useRef(null); + const overlay = useRef(null); + const hadFocus = useRef(false); + const refocus = useRef(false); + + const filterMap = useMemo( + () => + new Map( + Object.entries(filters).map(([column, value]) => [ + Number(column), + value, + ]), + ), + [filters], + ); + const order = useMemo(() => sortRows(sheet, sort), [sheet, sort]); + const view = useMemo( + () => filterRows(sheet, order, deferredQuery, filterMap), + [sheet, order, deferredQuery, filterMap], + ); + const shown = useMemo(() => view.slice(0, CSV_GRID_ROW_CAP), [view]); + const rank = useMemo(() => { + const places = new Int32Array(order.length); + order.forEach((index, place) => { + places[index] = place; + }); + return places; + }, [order]); + const lastShown = shown.length - 1; + const selection = range?.view === view ? range : null; + // Tied to the range it failed for, so moving the selection clears it. + const copyFailed = failedCopy !== null && failedCopy === selection; + const at = { + row: clamp(active.row, -1, lastShown), + column: clamp(active.column, 0, lastColumn), + }; + const [rowFrom, rowTo] = + selection === null + ? [-1, -1] + : spanOf(selection.anchor.row, selection.focus.row); + const [columnFrom, columnTo] = + selection === null + ? [-1, -1] + : spanOf(selection.anchor.column, selection.focus.column); + + const rangeTsv = useCallback( + ({ anchor, focus }: Range) => { + const [top, bottom] = spanOf(anchor.row, focus.row); + const [left, right] = spanOf(anchor.column, focus.column); + return toTsv( + view + .slice(top, bottom + 1) + .map((index) => (sheet.rows[index] ?? []).slice(left, right + 1)), + ); + }, + [sheet, view], + ); + + const reportRef = useRef(onSelectionChange); + reportRef.current = onSelectionChange; + const reported = useRef(null); + useEffect(() => { + const report = reportRef.current; + if (report === undefined || reported.current === selection) return; + reported.current = selection; + if (selection === null) { + report(null); + return; + } + const [top, bottom] = spanOf(selection.anchor.row, selection.focus.row); + const [left, right] = spanOf( + selection.anchor.column, + selection.focus.column, + ); + report({ + rows: bottom - top + 1, + columns: right - left + 1, + toTsv: () => rangeTsv(selection), + }); + }, [selection, rangeTsv]); + useEffect( + () => () => { + if (reported.current !== null) reportRef.current?.(null); + }, + [], + ); + + // A filter that drops the focused row moves focus to the nearest row left, + // in sheet order, rather than losing it to the page. + const shownBefore = useRef(view); + useLayoutEffect(() => { + const before = shownBefore.current; + shownBefore.current = view; + if (before === view || active.row < 0) return; + const index = before[active.row]; + if (index === undefined) return; + let row = view.indexOf(index); + if (row === -1) { + const place = rank[index] ?? 0; + row = view.findIndex((candidate) => (rank[candidate] ?? 0) >= place); + if (row === -1) row = view.length - 1; + } + if (row === -1) return; + refocus.current = hadFocus.current; + setActive({ + row: Math.min(row, CSV_GRID_ROW_CAP - 1), + column: active.column, + }); + }, [view]); + useLayoutEffect(() => { + if (!refocus.current) return; + refocus.current = false; + scroller.current + ?.querySelector(`[data-cell="${at.row}:${at.column}"]`) + ?.focus(); + }); + + // One outline around the whole range, drawn over the cells rather than as + // per-cell borders, which would also outline every inner edge. + useLayoutEffect(() => { + const box = overlay.current; + const frame = box?.parentElement; + if (box === null || frame === null || frame === undefined) return; + const first = frame.querySelector(`[data-cell="${rowFrom}:${columnFrom}"]`); + const last = frame.querySelector( + `[data-cell="${Math.min(rowTo, lastShown)}:${columnTo}"]`, + ); + box.hidden = rowFrom === -1 || first === null || last === null; + if (box.hidden || first === null || last === null) return; + const origin = frame.getBoundingClientRect(); + const start = first.getBoundingClientRect(); + const end = last.getBoundingClientRect(); + box.style.left = `${start.left - origin.left}px`; + box.style.top = `${start.top - origin.top}px`; + box.style.width = `${end.right - start.left}px`; + box.style.height = `${end.bottom - start.top}px`; + }, [rowFrom, rowTo, columnFrom, columnTo, lastShown, shown]); + + const selectCell = useCallback( + (cell: Cell, extend: boolean) => { + setActive(cell); + setRange((current) => ({ + view, + anchor: extend && current?.view === view ? current.anchor : cell, + focus: cell, + })); + }, + [view], + ); + + function selectAll() { + if (view.length === 0) return; + setRange({ + view, + anchor: { row: 0, column: 0 }, + focus: { row: view.length - 1, column: lastColumn }, + }); + } + + function moveTo(cell: Cell, extend: boolean) { + const target = { + row: clamp(cell.row, -1, lastShown), + column: clamp(cell.column, 0, lastColumn), + }; + setActive(target); + if (extend) { + const anchor = selection?.anchor ?? (at.row >= 0 ? at : null); + if (anchor !== null) { + setRange({ + view, + anchor, + focus: { row: Math.max(0, target.row), column: target.column }, + }); + } + } else if (target.row >= 0) { + setRange({ view, anchor: target, focus: target }); + } + scroller.current + ?.querySelector( + `[data-cell="${target.row}:${target.column}"]`, + ) + ?.focus(); + } + + function pageRows() { + const height = + scroller.current?.querySelector('[data-cell="0:0"]') + ?.offsetHeight ?? 0; + if (height === 0 || scroller.current === null) return 10; + return Math.max(1, Math.floor(scroller.current.clientHeight / height) - 2); + } + + async function copyRange(selected: Range) { + const written = await copy(rangeTsv(selected)); + setFailedCopy(written ? null : selected); + } + + function onKeyDown(event: KeyboardEvent) { + const command = event.metaKey || event.ctrlKey; + // Lowercased so Caps Lock or Shift does not turn Ctrl+C into a no-op. + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + + if (command && key === "a") { + event.preventDefault(); + selectAll(); + return; + } + if (command && key === "c" && selection !== null) { + event.preventDefault(); + void copyRange(selection); + return; + } + if (key === "Escape" && selection !== null) { + event.preventDefault(); + setRange(null); + return; + } + + const arrow = ARROWS[key]; + const edgeRow = (step: number) => (step < 0 ? 0 : lastShown); + const edgeColumn = (step: number) => (step < 0 ? 0 : lastColumn); + let next: Cell | null = null; + if (arrow !== undefined) { + next = command + ? { + row: arrow.row === 0 ? at.row : edgeRow(arrow.row), + column: arrow.column === 0 ? at.column : edgeColumn(arrow.column), + } + : { row: at.row + arrow.row, column: at.column + arrow.column }; + } else if (key === "Home") { + next = { row: command ? 0 : at.row, column: 0 }; + } else if (key === "End") { + next = { row: command ? lastShown : at.row, column: lastColumn }; + } else if (key === "PageUp" || key === "PageDown") { + const step = pageRows(); + next = { + row: at.row + (key === "PageUp" ? -step : step), + column: at.column, + }; + } + if (next === null) return; + event.preventDefault(); + moveTo(next, event.shiftKey); + } + + const notes = [ + view.length === sheet.rows.length + ? wording.count(sheet.rows.length, parsed.headers.length) + : wording.filteredCount( + view.length, + sheet.rows.length, + parsed.headers.length, + ), + ...(view.length > shown.length ? [wording.rendered(shown.length)] : []), + ...(columnCount < parsed.headers.length + ? [wording.columnsOf(columnCount, parsed.headers.length)] + : []), + ]; + + return ( +
+
+
+
+ { + hadFocus.current = true; + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + hadFocus.current = false; + } + }} + > + + + + {sheet.columns.map((column, index) => { + const direction = + sort?.column === index ? sort.direction : null; + return ( + + ); + })} + + + + {shown.map((index, position) => { + const inRange = position >= rowFrom && position <= rowTo; + return ( + + ); + })} + +
+ {wording.rowNumber} + + setActive({ row: -1, column: index })} + onClick={() => setSort(nextSort(sort, index))} + className={cn( + "flex h-8 w-full min-w-0 rounded-none px-2 whitespace-nowrap focus-visible:-outline-offset-2 [&>svg]:shrink-0", + column.numeric && "justify-end", + )} + > + + {columnTitle(sheet, index, wording)} + + +
+ + {view.length === 0 ? ( +

+ {sheet.rows.length === 0 ? wording.empty : wording.noMatches} +

+ ) : null} +
+
+

{notes.join(" · ")}

+

+ {copyFailed ? wording.copyFailed : copied ? wording.copied : ""} +

+ {selection === null ? null : ( +

+ {wording.selected(rowTo - rowFrom + 1, columnTo - columnFrom + 1)} +

+ )} +
+
+
+ ); +} + +const GridRow = memo(function GridRow({ + position, + label, + cells, + columns, + selectedFrom, + selectedTo, + activeColumn, + onSelectCell, +}: { + readonly position: number; + readonly label: number; + readonly cells: readonly string[]; + readonly columns: readonly CsvColumn[]; + readonly selectedFrom: number; + readonly selectedTo: number; + readonly activeColumn: number; + readonly onSelectCell: (cell: Cell, extend: boolean) => void; +}) { + return ( + + + {label.toLocaleString()} + + {columns.map((column, index) => { + const selected = index >= selectedFrom && index <= selectedTo; + return ( + + onSelectCell({ row: position, column: index }, event.shiftKey) + } + > + {cells[index] ?? ""} + + ); + })} + + ); +}); diff --git a/src/ui/sort-header-button.tsx b/src/ui/sort-header-button.tsx new file mode 100644 index 00000000..0b4df617 --- /dev/null +++ b/src/ui/sort-header-button.tsx @@ -0,0 +1,49 @@ +import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "../lib/utils.js"; + +export type SortDirection = "asc" | "desc"; + +export const ariaSort = (direction: SortDirection | null) => + direction === null + ? undefined + : direction === "asc" + ? "ascending" + : "descending"; + +/** + * The control inside a sortable column header. Inactive columns show a neutral + * affordance so it is discoverable which columns can be sorted at all; the + * `` around it carries `aria-sort`, since a caret is invisible to a screen + * reader. + */ +export function SortHeaderButton({ + direction, + className, + children, + ...props +}: React.ComponentProps<"button"> & { + readonly direction: SortDirection | null; +}) { + return ( + + ); +} diff --git a/src/ui/sortable-table.tsx b/src/ui/sortable-table.tsx index 63b37c74..78b9dc7d 100644 --- a/src/ui/sortable-table.tsx +++ b/src/ui/sortable-table.tsx @@ -1,11 +1,12 @@ "use client"; -import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react"; import { useMemo, useState } from "react"; import type { ReactNode } from "react"; import { cn } from "../lib/utils.js"; import { EmptyState } from "./empty-state.js"; +import { SortHeaderButton, ariaSort } from "./sort-header-button.js"; +import type { SortDirection } from "./sort-header-button.js"; import { Table, TableBody, @@ -15,7 +16,7 @@ import { TableRow, } from "./table.js"; -export type SortDirection = "asc" | "desc"; +export type { SortDirection } from "./sort-header-button.js"; export type SortableColumn = { /** Stable id — the React key and the sort key. */ @@ -109,49 +110,26 @@ export function SortableTable({ {columns.map((column) => { - const active = sort?.key === column.key; - const sortable = column.sortValue !== undefined; + const direction = sort?.key === column.key ? sort.direction : null; return ( - {sortable ? ( - - ) : ( - column.header + )} ); diff --git a/stories/primitives/csv-table.stories.tsx b/stories/primitives/csv-table.stories.tsx new file mode 100644 index 00000000..ba789e19 --- /dev/null +++ b/stories/primitives/csv-table.stories.tsx @@ -0,0 +1,178 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import { useCopy } from "../../src/hooks/use-copy.js"; +import { CSV_MAX_PREVIEW_BYTES } from "../../src/lib/csv.js"; +import { Button } from "../../src/ui/button.js"; +import { CsvTable, csvFilterSpecs } from "../../src/ui/csv-table.js"; +import type { CsvFilters, CsvSelection } from "../../src/ui/csv-table.js"; +import { FilterBar } from "../../src/ui/filter-bar.js"; +import { Input } from "../../src/ui/input.js"; + +export default { title: "Primitives / CSV Table" }; + +const statuses = ["Succeeded", "Running", "Failed", ""]; +const owners = ["data-platform", "billing", "growth"]; +const notes = [ + "Retried after a timeout fetching the source", + "", + "Manual run requested by the on-call engineer", + "Backfill for the March gap", +]; + +const runsOf = (count: number) => + [ + "Run,Owner,Status,Cost,Duration (s),Notes", + ...Array.from({ length: count }, (_, index) => + [ + `run-${String(index + 1).padStart(4, "0")}`, + owners[index % owners.length], + statuses[index % statuses.length], + index % 7 === 0 + ? `"(${(index * 3).toLocaleString("en-US")}.50)"` + : `$${index * 2}.25`, + index % 11 === 0 ? "" : String((index * 37) % 900), + notes[index % notes.length], + ].join(","), + ), + ].join("\n"); + +// Enough rows to scroll the pane; a sheet past the row cap is covered by the +// unit tests, since thousands of rendered cells stall the accessibility sweep. +const runs = runsOf(40); + +export const Static = () => ( + +); + +export const Interactive = () => { + const [selection, setSelection] = useState(null); + return ( +
+ +
+        {selection === null ? "No selection" : selection.toTsv()}
+      
+
+ ); +}; + +/** Clicks one cell, then shift-clicks another, as a person would. */ +function useStartSelected(from: string, to: string) { + const host = useRef(null); + useEffect(() => { + const cell = (at: string) => + host.current?.querySelector(`[data-cell="${at}"]`); + cell(from)?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + cell(to)?.dispatchEvent( + new MouseEvent("click", { bubbles: true, shiftKey: true }), + ); + }, [from, to]); + return host; +} + +export const Selection = () => { + const host = useStartSelected("2:3", "7:4"); + return ( +
+ +
+ ); +}; + +/** + * The grid has no controls of its own: the host renders search, filters and + * copy in its bar and drives the grid; the sheet fills the pane, flush. + */ +export const HostControlled = () => { + const [query, setQuery] = useState(""); + const [filters, setFilters] = useState({}); + const [selection, setSelection] = useState(null); + const specs = useMemo(() => csvFilterSpecs(runs, filters), [filters]); + const { copied, copy } = useCopy(); + return ( +
+
+ +
+
+ setQuery(event.target.value)} + /> + + setFilters((current) => { + const { [id]: _, ...rest } = current; + return value === null ? rest : { ...rest, [id]: value }; + }) + } + /> + +
+
+ ); +}; + +export const InteractiveRelabelled = () => ( + + `${rows.toLocaleString()} records, ${columns.toLocaleString()} fields`, + }} + /> +); + +export const Narrow = () => ( +
+ +
+); + +export const NoMatches = () => ( + +); + +export const Empty = () => ( + +); + +export const TooLarge = () => ( + +); + +export const Ragged = () => ( + +);