diff --git a/e2e/stories-a11y.known.json b/e2e/stories-a11y.known.json index 997ca92..2891c6f 100644 --- a/e2e/stories-a11y.known.json +++ b/e2e/stories-a11y.known.json @@ -8,5 +8,7 @@ "primitives-csv-table--resized-column (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--resized-column (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.", "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." + "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.", + "primitives-csv-table--sheets (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--sheets (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/ui/csv-table.test.tsx b/src/ui/csv-table.test.tsx index 907141b..578bd9a 100644 --- a/src/ui/csv-table.test.tsx +++ b/src/ui/csv-table.test.tsx @@ -203,4 +203,27 @@ describe("CsvTable interactive", () => { expect(selections.at(-1)).toBeNull(); unmount(); }); + + test("arrow keys move between sheet tabs and open the sheet", () => { + const { container, firstColumn, unmount } = mount({ + interactive: true, + sheets: [ + { name: "One", text: "name,n\na,1\nb,2" }, + { name: "Two", text: "name,n\nz,9\ny,8" }, + ], + }); + const tabs = () => [...container.querySelectorAll('[role="tab"]')]; + act(() => { + tabs()[0]?.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), + ); + }); + expect(tabs().map((tab) => tab.getAttribute("aria-selected"))).toEqual([ + "false", + "true", + ]); + expect(document.activeElement).toBe(tabs()[1] ?? null); + expect(firstColumn()).toEqual(["z", "y"]); + unmount(); + }); }); diff --git a/src/ui/csv-table.tsx b/src/ui/csv-table.tsx index 9c5a2d3..b34c903 100644 --- a/src/ui/csv-table.tsx +++ b/src/ui/csv-table.tsx @@ -5,6 +5,7 @@ import { useCallback, useDeferredValue, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -58,6 +59,7 @@ export type CsvTableLabels = { readonly nameBox: string; readonly cellContents: string; readonly resizeHint: string; + readonly sheets: string; readonly count: (rows: number, columns: number) => string; readonly filteredCount: ( matching: number, @@ -95,6 +97,7 @@ const LABELS: CsvTableLabels = { cellContents: "Cell contents", resizeHint: "Alt+Left and Alt+Right resize the column, Alt+Enter fits it to its contents", + sheets: "Sheets", count: (rows, columns) => `${counted(rows, "row", "rows")} · ${counted(columns, "column", "columns")}`, filteredCount: (matching, total, columns) => @@ -126,8 +129,19 @@ export type CsvSelection = { /** Column filters keyed by the column's zero-based index, as `FilterSpec.id`. */ export type CsvFilters = Readonly>; -export type CsvTableProps = { +export type CsvTableSheet = { + readonly name: string; readonly text: string; +}; + +export type CsvTableProps = ( + | { readonly text: string; readonly sheets?: undefined } + | { + /** Several sheets of one workbook, switched by tabs under the grid. */ + readonly sheets: readonly CsvTableSheet[]; + readonly text?: undefined; + } +) & { readonly caption?: string | undefined; /** * A compact spreadsheet grid that sorts, selects and copies, and applies the @@ -144,12 +158,25 @@ export type CsvTableProps = { readonly filters?: CsvFilters | undefined; readonly sort?: CsvSort | null | undefined; readonly onSortChange?: ((sort: CsvSort | null) => void) | undefined; + /** + * The open sheet's index in `sheets`. Sort, query and filters belong to one + * sheet: the grid's own sort starts over on a switch, and a host that owns + * `query`, `filters` or `sort` resets them in `onSheetChange` and builds + * `csvFilterSpecs` from the open sheet's text. + */ + readonly sheet?: number | undefined; + readonly onSheetChange?: ((sheet: number) => void) | undefined; readonly labels?: Partial | undefined; readonly onSelectionChange?: | ((selection: CsvSelection | null) => void) | undefined; }; +type ViewProps = Omit< + CsvTableProps, + "text" | "sheets" | "sheet" | "onSheetChange" | "labels" +>; + const NO_FILTERS: CsvFilters = {}; const columnTitle = ( @@ -220,25 +247,188 @@ export function csvFilterSpecs( * freely and headers are routinely empty or repeated ("", two "Name" columns); * a content key would collide and drop data. */ -export function CsvTable({ +export function CsvTable(props: CsvTableProps) { + const { sheet, onSheetChange, labels, ...view } = props; + const wording = { ...LABELS, ...labels }; + const [open, setOpen] = useControllableState({ + value: sheet, + defaultValue: 0, + onChange: onSheetChange, + name: "CsvTable sheet", + }); + const ids = useId(); + // Column widths outlive a sheet switch, keyed by the sheet's text, so going + // back to a sheet finds its columns as they were left. + const widths = useRef(new Map()); + + if (props.sheets === undefined) { + return ( + + ); + } + const index = clamp(open, 0, props.sheets.length - 1); + const current = props.sheets[index]; + if (current === undefined) { + return {wording.empty}; + } + // The tabs sit outside the keyed sheet, so switching keeps focus on the tab. + return ( +
+
+ +
+ entry.name)} + active={index} + onChange={setOpen} + label={wording.sheets} + /> +
+ ); +} + +function Notice({ + children, + tabbed = false, +}: { + readonly children: ReactNode; + readonly tabbed?: boolean; +}) { + return ( +

+ {children} +

+ ); +} + +/** + * The tab pattern `Tabs` follows: one tab stop, arrows and Home and End move + * the selection and focus with it, and only the open sheet's panel exists. + */ +function SheetTabs({ + ids, + names, + active, + onChange, + label, +}: { + readonly ids: string; + readonly names: readonly string[]; + readonly active: number; + readonly onChange: (sheet: number) => void; + readonly label: string; +}) { + const buttons = useRef<(HTMLButtonElement | null)[]>([]); + + function focusTab(index: number) { + onChange(index); + buttons.current[index]?.focus(); + } + + function onKeyDown(event: KeyboardEvent) { + const last = names.length - 1; + const targets: Record = { + ArrowRight: active === last ? 0 : active + 1, + ArrowLeft: active === 0 ? last : active - 1, + Home: 0, + End: last, + }; + const next = targets[event.key]; + if (next === undefined) return; + event.preventDefault(); + focusTab(next); + } + + return ( +
+ {names.map((name, index) => { + const selected = index === active; + return ( + + ); + })} +
+ ); +} + +function CsvSheetView({ text, caption = "CSV contents", interactive = false, - labels, + wording, + widthStore, + tabbed = false, ...grid -}: CsvTableProps) { +}: ViewProps & { + readonly text: string; + readonly wording: CsvTableLabels; + readonly widthStore: Map; + readonly tabbed?: boolean; +}) { const parsed = useMemo(() => parseCsv(text), [text]); - const wording = { ...LABELS, ...labels }; if (parsed === null) { - return {wording.tooLarge}; + return {wording.tooLarge}; } if (!isTabular(parsed)) { // Height-capped with its own scroll: a multi-thousand-line file as one //
 is the same DOM freeze the size guard exists to prevent.
     return (
-      
+      
         {text}
       
); @@ -246,7 +436,7 @@ 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. + // selection, widths or, unless the host owns them, sort, filters and query. return ( widthStore.set(text, widths)} /> ); } @@ -269,7 +462,12 @@ export function CsvTable({ return (
-
+
{/* Focusable so the keyboard can scroll it. */}
- {children} -

- ); -} - /** Row -1 is the CSV header row and column -1 the row numbers. */ type Cell = { readonly row: number; readonly column: number }; type Range = { @@ -453,15 +643,21 @@ function CsvGrid({ parsed, caption, wording, + tabbed, + storedWidths, + onWidthsChange, query = "", filters = NO_FILTERS, sort: sortProp, onSortChange, onSelectionChange, -}: Omit & { +}: Omit & { readonly parsed: ParsedCsv; readonly caption: string; readonly wording: CsvTableLabels; + readonly tabbed: boolean; + readonly storedWidths: readonly number[] | undefined; + readonly onWidthsChange: (widths: readonly number[]) => void; }) { const columnCount = Math.min(parsed.headers.length, CSV_COLUMN_CAP); const lastColumn = columnCount - 1; @@ -477,7 +673,9 @@ function CsvGrid({ }); const [range, setRange] = useState(null); const [active, setActive] = useState({ row: -1, column: 0 }); - const [widths, setWidths] = useState(null); + const [widths, setWidths] = useState( + storedWidths ?? null, + ); const [metrics, setMetrics] = useState(null); const [failedCopy, setFailedCopy] = useState(null); const { copied, copy } = useCopy(); @@ -578,6 +776,12 @@ function CsvGrid({ [], ); + const widthsChange = useRef(onWidthsChange); + widthsChange.current = onWidthsChange; + useEffect(() => { + if (widths !== null) widthsChange.current(widths); + }, [widths]); + // Columns start at their content's width, measured as text rather than read // from a first layout a narrow container would already have squeezed. // Fixed widths then let a resize, a sort or a filter re-lay one , not @@ -1013,7 +1217,12 @@ function CsvGrid({ return (
-
+
) : null}
-
+

{notes.join(" · ")}

{copyFailed ? wording.copyFailed : copied ? wording.copied : ""} diff --git a/stories/primitives/csv-table.stories.tsx b/stories/primitives/csv-table.stories.tsx index 9e533ce..30b330d 100644 --- a/stories/primitives/csv-table.stories.tsx +++ b/stories/primitives/csv-table.stories.tsx @@ -61,6 +61,13 @@ export const Interactive = () => { ); }; +const budget = [ + "Team,Q1,Q2,Q3,Q4", + "Platform,120,135,150,160", + "Billing,80,82,90,95", + "Growth,60,75,88,102", +].join("\n"); + const press = (target: Element, key: string, init: KeyboardEventInit = {}) => target.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, ...init }), @@ -128,6 +135,23 @@ export const ResizedColumn = () => { ); }; +export const Sheets = () => { + const host = useStartSelected("0:1", "2: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. @@ -182,6 +206,58 @@ export const HostControlled = () => { ); }; +const workbook = [ + { name: "Runs", text: runs }, + { name: "Budget", text: budget }, +]; + +/** Query and filters belong to the open sheet: the host resets them on a switch. */ +export const SheetsHostControlled = () => { + const [sheet, setSheet] = useState(0); + const [query, setQuery] = useState(""); + const [filters, setFilters] = useState({}); + const text = workbook[sheet]?.text ?? ""; + const specs = useMemo(() => csvFilterSpecs(text, filters), [text, filters]); + return ( +
+
+ { + setSheet(next); + setQuery(""); + setFilters({}); + }} + query={query} + filters={filters} + /> +
+
+ setQuery(event.target.value)} + /> + + setFilters((current) => { + const { [id]: _, ...rest } = current; + return value === null ? rest : { ...rest, [id]: value }; + }) + } + /> +
+
+ ); +}; + export const InteractiveRelabelled = () => (