Skip to content

Commit 2dceac2

Browse files
committed
feat(csv-table): resizable columns sized to their contents
1 parent 4ff95b3 commit 2dceac2

3 files changed

Lines changed: 289 additions & 4 deletions

File tree

‎e2e/stories-a11y.known.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
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.",
77
"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--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.",
9+
"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.",
810
"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.",
911
"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."
1012
}

‎src/ui/csv-table.tsx‎

Lines changed: 243 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import {
1010
useRef,
1111
useState,
1212
} from "react";
13-
import type { KeyboardEvent, MouseEvent, ReactNode } from "react";
13+
import type {
14+
KeyboardEvent,
15+
MouseEvent,
16+
PointerEvent as ReactPointerEvent,
17+
ReactNode,
18+
} from "react";
1419

1520
import { useControllableState } from "../hooks/use-controllable-state.js";
1621
import { useCopy } from "../hooks/use-copy.js";
@@ -52,6 +57,7 @@ export type CsvTableLabels = {
5257
readonly selectAll: string;
5358
readonly nameBox: string;
5459
readonly cellContents: string;
60+
readonly resizeHint: string;
5561
readonly count: (rows: number, columns: number) => string;
5662
readonly filteredCount: (
5763
matching: number,
@@ -87,6 +93,8 @@ const LABELS: CsvTableLabels = {
8793
selectAll: "Select all",
8894
nameBox: "Selected cell",
8995
cellContents: "Cell contents",
96+
resizeHint:
97+
"Alt+Left and Alt+Right resize the column, Alt+Enter fits it to its contents",
9098
count: (rows, columns) =>
9199
`${counted(rows, "row", "rows")} · ${counted(columns, "column", "columns")}`,
92100
filteredCount: (matching, total, columns) =>
@@ -319,6 +327,26 @@ type Range = {
319327
/** Picked by row numbers or column letters, and addressed as such: `3:5`, `B:D`. */
320328
readonly whole?: "rows" | "columns";
321329
};
330+
type TextStyle = {
331+
readonly font: string;
332+
/** One figure's width with tabular figures on. */
333+
readonly figure: number;
334+
readonly padding: number;
335+
};
336+
type Metrics = {
337+
readonly head: TextStyle;
338+
readonly cell: TextStyle;
339+
readonly gutter: TextStyle;
340+
/** The sort icon and its gap beside a header's text. */
341+
readonly headChrome: number;
342+
};
343+
344+
const COLUMN_MIN_PX = 32;
345+
/** The widest a column starts; a long note column would push the rest off-screen. */
346+
const COLUMN_START_MAX_PX = 320;
347+
const COLUMN_MAX_PX = 960;
348+
const RESIZE_STEP_PX = 16;
349+
const RESIZE_KEYS = "Alt+ArrowLeft Alt+ArrowRight Alt+Enter";
322350

323351
const clamp = (value: number, min: number, max: number) =>
324352
Math.max(min, Math.min(max, value));
@@ -344,6 +372,59 @@ const keepTextUnselected = (event: MouseEvent) => {
344372
if (event.shiftKey) event.preventDefault();
345373
};
346374

375+
const padding = (style: CSSStyleDeclaration) =>
376+
Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight);
377+
378+
// Built from the longhands: the computed `font` shorthand is empty in some
379+
// engines.
380+
const fontOf = (style: CSSStyleDeclaration) =>
381+
`${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
382+
383+
let canvas: CanvasRenderingContext2D | null | undefined;
384+
const textContext = () =>
385+
canvas === undefined
386+
? (canvas = document.createElement("canvas").getContext("2d"))
387+
: canvas;
388+
389+
/** Canvas has no tabular figures, so one figure is measured in the DOM. */
390+
function tabularFigure(font: string) {
391+
const probe = document.createElement("span");
392+
probe.style.cssText =
393+
"position:absolute;visibility:hidden;white-space:pre;font-variant-numeric:tabular-nums";
394+
probe.style.font = font;
395+
probe.textContent = "0123456789";
396+
document.body.append(probe);
397+
const width = probe.getBoundingClientRect().width / 10;
398+
probe.remove();
399+
return width;
400+
}
401+
402+
function textStyle(element: Element): TextStyle {
403+
const style = getComputedStyle(element);
404+
const font = fontOf(style);
405+
return { font, figure: tabularFigure(font), padding: padding(style) };
406+
}
407+
408+
const FIGURES = /\d/g;
409+
410+
function textWidth(
411+
context: CanvasRenderingContext2D,
412+
style: TextStyle,
413+
text: string,
414+
tabular: boolean,
415+
) {
416+
context.font = style.font;
417+
if (!tabular) return context.measureText(text).width;
418+
const figures = text.match(FIGURES)?.length ?? 0;
419+
return (
420+
context.measureText(text.replace(FIGURES, "")).width +
421+
figures * style.figure
422+
);
423+
}
424+
425+
// A pixel for the cell's border, and one for sub-pixel text rounding.
426+
const fitted = (width: number) => Math.ceil(width) + 2;
427+
347428
/**
348429
* Selection positions index the sorted, filtered view, so a copy is what the
349430
* person sees, and it spans every matching row, including those past the
@@ -383,6 +464,8 @@ function CsvGrid({
383464
});
384465
const [range, setRange] = useState<Range | null>(null);
385466
const [active, setActive] = useState<Cell>({ row: -1, column: 0 });
467+
const [widths, setWidths] = useState<readonly number[] | null>(null);
468+
const [metrics, setMetrics] = useState<Metrics | null>(null);
386469
const [failedCopy, setFailedCopy] = useState<Range | null>(null);
387470
const { copied, copy } = useCopy();
388471
const deferredQuery = useDeferredValue(query);
@@ -481,6 +564,58 @@ function CsvGrid({
481564
[],
482565
);
483566

567+
// Columns start at their content's width, measured as text rather than read
568+
// from a first layout a narrow container would already have squeezed.
569+
// Fixed widths then let a resize, a sort or a filter re-lay one <col>, not
570+
// every cell. Without canvas text metrics the grid stays on automatic layout.
571+
useLayoutEffect(() => {
572+
const head = scroller.current?.querySelector('[data-cell="-1:0"]');
573+
const gutter = scroller.current?.querySelector('[data-cell="-1:-1"]');
574+
const context = textContext();
575+
if (!head || !gutter || context === null) return;
576+
const headStyle = getComputedStyle(head);
577+
const measured: Metrics = {
578+
head: textStyle(head),
579+
cell: textStyle(
580+
scroller.current?.querySelector('[data-cell="0:0"]') ?? gutter,
581+
),
582+
// Selected row numbers are semibold, so the column fits the heavier weight.
583+
gutter: { ...textStyle(gutter), font: fontOf(headStyle) },
584+
headChrome:
585+
(head.querySelector("svg")?.getBoundingClientRect().width ?? 0) +
586+
Number.parseFloat(headStyle.columnGap || "0"),
587+
};
588+
setMetrics(measured);
589+
setWidths(
590+
(current) =>
591+
current ??
592+
sheet.columns.map((_, column) =>
593+
clamp(
594+
columnWidth(context, measured, column),
595+
COLUMN_MIN_PX,
596+
COLUMN_START_MAX_PX,
597+
),
598+
),
599+
);
600+
}, []);
601+
602+
const context = metrics === null ? null : textContext();
603+
const gutterWidth =
604+
metrics === null || context === null
605+
? null
606+
: fitted(
607+
textWidth(
608+
context,
609+
metrics.gutter,
610+
(order.length + 1).toLocaleString(),
611+
true,
612+
) + metrics.gutter.padding,
613+
);
614+
const dataWidth =
615+
widths === null || gutterWidth === null
616+
? null
617+
: gutterWidth + widths.reduce((sum, width) => sum + width, 0);
618+
484619
// A filter that drops the focused row moves focus to the nearest row left,
485620
// in sheet order, rather than losing it to the page.
486621
const shownBefore = useRef(view);
@@ -530,7 +665,76 @@ function CsvGrid({
530665
box.style.top = `${start.top - origin.top}px`;
531666
box.style.width = `${end.right - start.left}px`;
532667
box.style.height = `${end.bottom - start.top}px`;
533-
}, [rowFrom, rowTo, columnFrom, columnTo, lastShown, shown]);
668+
}, [rowFrom, rowTo, columnFrom, columnTo, lastShown, widths, shown]);
669+
670+
const resizeColumn = useCallback((column: number, width: number) => {
671+
setWidths(
672+
(current) =>
673+
current &&
674+
current.map((previous, index) =>
675+
index === column
676+
? clamp(Math.round(width), COLUMN_MIN_PX, COLUMN_MAX_PX)
677+
: previous,
678+
),
679+
);
680+
}, []);
681+
682+
/** A column's content width: its header and every rendered cell. */
683+
function columnWidth(
684+
context: CanvasRenderingContext2D,
685+
measured: Metrics,
686+
column: number,
687+
) {
688+
const numeric = sheet.columns[column]?.numeric ?? false;
689+
let widest =
690+
textWidth(
691+
context,
692+
measured.head,
693+
columnTitle(sheet, column, wording),
694+
false,
695+
) +
696+
measured.headChrome +
697+
measured.head.padding;
698+
for (const index of shown) {
699+
widest = Math.max(
700+
widest,
701+
textWidth(
702+
context,
703+
measured.cell,
704+
sheet.rows[index]?.[column] ?? "",
705+
numeric,
706+
) + measured.cell.padding,
707+
);
708+
}
709+
return fitted(widest);
710+
}
711+
712+
function autoFit(column: number) {
713+
if (metrics === null || context === null) return;
714+
resizeColumn(column, columnWidth(context, metrics, column));
715+
}
716+
717+
function startResize(
718+
column: number,
719+
event: ReactPointerEvent<HTMLDivElement>,
720+
) {
721+
const from = widths?.[column];
722+
if (from === undefined) return;
723+
event.preventDefault();
724+
const handle = event.currentTarget;
725+
const start = event.clientX;
726+
handle.setPointerCapture(event.pointerId);
727+
const move = (next: PointerEvent) =>
728+
resizeColumn(column, from + next.clientX - start);
729+
const end = () => {
730+
handle.removeEventListener("pointermove", move);
731+
handle.removeEventListener("pointerup", end);
732+
handle.removeEventListener("pointercancel", end);
733+
};
734+
handle.addEventListener("pointermove", move);
735+
handle.addEventListener("pointerup", end);
736+
handle.addEventListener("pointercancel", end);
737+
}
534738

535739
const selectCell = useCallback(
536740
(cell: Cell, extend: boolean) => {
@@ -631,6 +835,20 @@ function CsvGrid({
631835
// Lowercased so Caps Lock or Shift does not turn Ctrl+C into a no-op.
632836
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
633837

838+
if (event.altKey && at.column >= 0 && widths !== null) {
839+
const width = widths[at.column] ?? COLUMN_MIN_PX;
840+
const step = RESIZE_STEP_PX * (event.shiftKey ? 4 : 1);
841+
if (key === "ArrowLeft" || key === "ArrowRight") {
842+
event.preventDefault();
843+
resizeColumn(at.column, width + (key === "ArrowLeft" ? -step : step));
844+
return;
845+
}
846+
if (key === "Enter") {
847+
event.preventDefault();
848+
autoFit(at.column);
849+
return;
850+
}
851+
}
634852
if (command && key === "a") {
635853
event.preventDefault();
636854
selectAll();
@@ -732,6 +950,7 @@ function CsvGrid({
732950
wording.cellCount(summary.count),
733951
...(summary.numbers > 0 ? [wording.sum(summary.sum)] : []),
734952
];
953+
const frozen = widths !== null && gutterWidth !== null && dataWidth !== null;
735954

736955
return (
737956
<div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
@@ -758,7 +977,11 @@ function CsvGrid({
758977
aria-label={caption}
759978
aria-multiselectable="true"
760979
aria-rowcount={view.length + 2}
761-
className="min-w-full border-separate border-spacing-0 [&_td]:max-w-80"
980+
className={cn(
981+
"border-separate border-spacing-0",
982+
frozen ? "table-fixed" : "min-w-full [&_td]:max-w-80",
983+
)}
984+
style={frozen ? { width: dataWidth } : undefined}
762985
onKeyDown={onKeyDown}
763986
onFocus={() => {
764987
hadFocus.current = true;
@@ -769,6 +992,14 @@ function CsvGrid({
769992
}
770993
}}
771994
>
995+
{frozen ? (
996+
<colgroup>
997+
<col style={{ width: gutterWidth }} />
998+
{widths.map((width, index) => (
999+
<col key={index} style={{ width }} />
1000+
))}
1001+
</colgroup>
1002+
) : null}
7721003
<thead className="sticky top-0 z-3">
7731004
<tr aria-rowindex={1}>
7741005
<th
@@ -823,14 +1054,15 @@ function CsvGrid({
8231054
key={index}
8241055
scope="col"
8251056
aria-sort={ariaSort(direction)}
826-
className="h-8 border-r border-b border-border bg-card p-0 text-left font-semibold"
1057+
className="relative h-8 border-r border-b border-border bg-card p-0 text-left font-semibold"
8271058
>
8281059
<SortHeaderButton
8291060
direction={direction}
8301061
data-cell={`-1:${index}`}
8311062
tabIndex={
8321063
at.row === -1 && at.column === index ? 0 : -1
8331064
}
1065+
aria-keyshortcuts={RESIZE_KEYS}
8341066
onFocus={() => setActive({ row: -1, column: index })}
8351067
onClick={() => setSort(nextSort(sort, index))}
8361068
className={cn(
@@ -842,6 +1074,13 @@ function CsvGrid({
8421074
{columnTitle(sheet, index, wording)}
8431075
</span>
8441076
</SortHeaderButton>
1077+
<div
1078+
aria-hidden
1079+
title={wording.resizeHint}
1080+
onPointerDown={(event) => startResize(index, event)}
1081+
onDoubleClick={() => autoFit(index)}
1082+
className="absolute inset-y-0 -right-1 z-1 w-2 cursor-col-resize touch-none hover:bg-ring/40"
1083+
/>
8451084
</th>
8461085
);
8471086
})}

0 commit comments

Comments
 (0)