Skip to content

Commit 7e33661

Browse files
feat(csv-table): run gridlines out to the pane's edges (#159)
1 parent 65665f6 commit 7e33661

1 file changed

Lines changed: 122 additions & 3 deletions

File tree

‎src/ui/csv-table.tsx‎

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,15 @@ type Range = {
327327
/** Picked by row numbers or column letters, and addressed as such: `3:5`, `B:D`. */
328328
readonly whole?: "rows" | "columns";
329329
};
330+
type Fill = {
331+
readonly columns: number;
332+
readonly rows: number;
333+
/** The scroller's inner size, and the header and row heights, in px. */
334+
readonly width: number;
335+
readonly head: number;
336+
readonly row: number;
337+
};
338+
const NO_FILL: Fill = { columns: 0, rows: 0, width: 0, head: 0, row: 0 };
330339
type TextStyle = {
331340
readonly font: string;
332341
/** One figure's width with tabular figures on. */
@@ -346,6 +355,10 @@ const COLUMN_MIN_PX = 32;
346355
const COLUMN_START_MAX_PX = 320;
347356
const COLUMN_MAX_PX = 960;
348357
const RESIZE_STEP_PX = 16;
358+
/** The width of the empty columns that run the grid out to its container's edge. */
359+
const FILL_COLUMN_PX = 64;
360+
const FILL_COLUMN_LINES =
361+
"linear-gradient(to left, var(--border) 1px, transparent 1px)";
349362
const RESIZE_KEYS = "Alt+ArrowLeft Alt+ArrowRight Alt+Enter";
350363

351364
const clamp = (value: number, min: number, max: number) =>
@@ -471,6 +484,7 @@ function CsvGrid({
471484
const deferredQuery = useDeferredValue(query);
472485
const scroller = useRef<HTMLDivElement>(null);
473486
const overlay = useRef<HTMLDivElement>(null);
487+
const [fill, setFill] = useState<Fill>(NO_FILL);
474488
const hadFocus = useRef(false);
475489
const refocus = useRef(false);
476490

@@ -607,14 +621,58 @@ function CsvGrid({
607621
textWidth(
608622
context,
609623
metrics.gutter,
610-
(order.length + 1).toLocaleString(),
624+
(order.length + 1 + fill.rows).toLocaleString(),
611625
true,
612626
) + metrics.gutter.padding,
613627
);
614628
const dataWidth =
615629
widths === null || gutterWidth === null
616630
? null
617631
: gutterWidth + widths.reduce((sum, width) => sum + width, 0);
632+
const showsRows = view.length > 0;
633+
634+
// Empty gridlines out to the container's edges, as a spreadsheet draws
635+
// them: only as many as the visible area holds, re-counted when it resizes.
636+
useLayoutEffect(() => {
637+
const box = scroller.current;
638+
if (box === null || dataWidth === null) return;
639+
function measure() {
640+
if (box === null || dataWidth === null) return;
641+
const head = box.querySelector("thead")?.offsetHeight ?? 0;
642+
const row = box.querySelector<HTMLElement>("tbody th")?.offsetHeight ?? 0;
643+
// Whole rows only, so a short sheet never scrolls; the last empty column
644+
// takes what is left, so a wide one never does either.
645+
const next: Fill = {
646+
width: box.clientWidth,
647+
head,
648+
row,
649+
columns: Math.max(
650+
0,
651+
Math.ceil((box.clientWidth - dataWidth) / FILL_COLUMN_PX),
652+
),
653+
rows:
654+
row === 0
655+
? 0
656+
: Math.max(
657+
0,
658+
Math.floor((box.clientHeight - head) / row) - shown.length,
659+
),
660+
};
661+
setFill((current) =>
662+
current.width === next.width &&
663+
current.head === next.head &&
664+
current.row === next.row &&
665+
current.columns === next.columns &&
666+
current.rows === next.rows
667+
? current
668+
: next,
669+
);
670+
}
671+
measure();
672+
const observer = new ResizeObserver(measure);
673+
observer.observe(box);
674+
return () => observer.disconnect();
675+
}, [dataWidth, shown.length]);
618676

619677
// A filter that drops the focused row moves focus to the nearest row left,
620678
// in sheet order, rather than losing it to the page.
@@ -665,7 +723,7 @@ function CsvGrid({
665723
box.style.top = `${start.top - origin.top}px`;
666724
box.style.width = `${end.right - start.left}px`;
667725
box.style.height = `${end.bottom - start.top}px`;
668-
}, [rowFrom, rowTo, columnFrom, columnTo, lastShown, widths, shown]);
726+
}, [rowFrom, rowTo, columnFrom, columnTo, lastShown, widths, shown, fill]);
669727

670728
const resizeColumn = useCallback((column: number, width: number) => {
671729
setWidths(
@@ -951,6 +1009,7 @@ function CsvGrid({
9511009
...(summary.numbers > 0 ? [wording.sum(summary.sum)] : []),
9521010
];
9531011
const frozen = widths !== null && gutterWidth !== null && dataWidth !== null;
1012+
const fillers = Array.from({ length: fill.columns }, (_, index) => index);
9541013

9551014
return (
9561015
<div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
@@ -972,6 +1031,18 @@ function CsvGrid({
9721031
</div>
9731032
<div ref={scroller} className="min-h-0 flex-[1_1_32rem] overflow-auto">
9741033
<div className="relative isolate w-max min-w-full">
1034+
{frozen && fill.columns > 0 ? (
1035+
<div
1036+
aria-hidden
1037+
className="absolute inset-y-0 right-0 -z-1"
1038+
style={{
1039+
left: dataWidth,
1040+
backgroundImage: `${FILL_COLUMN_LINES}, linear-gradient(to top, var(--border) 1px, transparent 1px)`,
1041+
backgroundSize: `${FILL_COLUMN_PX}px 100%, 100% ${fill.row}px`,
1042+
backgroundPosition: `0 0, 0 ${fill.head}px`,
1043+
}}
1044+
/>
1045+
) : null}
9751046
<table
9761047
role="grid"
9771048
aria-label={caption}
@@ -981,7 +1052,9 @@ function CsvGrid({
9811052
"border-separate border-spacing-0",
9821053
frozen ? "table-fixed" : "min-w-full [&_td]:max-w-80",
9831054
)}
984-
style={frozen ? { width: dataWidth } : undefined}
1055+
style={
1056+
frozen ? { width: Math.max(dataWidth, fill.width) } : undefined
1057+
}
9851058
onKeyDown={onKeyDown}
9861059
onFocus={() => {
9871060
hadFocus.current = true;
@@ -998,6 +1071,17 @@ function CsvGrid({
9981071
{widths.map((width, index) => (
9991072
<col key={index} style={{ width }} />
10001073
))}
1074+
{fillers.map((index) => (
1075+
<col
1076+
key={`fill-${index}`}
1077+
style={{
1078+
width:
1079+
index === fill.columns - 1
1080+
? undefined
1081+
: FILL_COLUMN_PX,
1082+
}}
1083+
/>
1084+
))}
10011085
</colgroup>
10021086
) : null}
10031087
<thead className="sticky top-0 z-3">
@@ -1035,6 +1119,15 @@ function CsvGrid({
10351119
{columnLetter(index)}
10361120
</th>
10371121
))}
1122+
{fillers.map((index) => (
1123+
<th
1124+
key={`fill-${index}`}
1125+
aria-hidden
1126+
className="h-6 border-r border-b border-border bg-card px-1 text-center font-normal text-muted-foreground"
1127+
>
1128+
{columnLetter(columnCount + index)}
1129+
</th>
1130+
))}
10381131
</tr>
10391132
<tr aria-rowindex={2}>
10401133
<th
@@ -1084,6 +1177,17 @@ function CsvGrid({
10841177
</th>
10851178
);
10861179
})}
1180+
{fill.columns > 0 ? (
1181+
<th
1182+
aria-hidden
1183+
colSpan={fill.columns}
1184+
className="h-8 border-b border-border bg-background"
1185+
style={{
1186+
backgroundImage: FILL_COLUMN_LINES,
1187+
backgroundSize: `${FILL_COLUMN_PX}px 100%`,
1188+
}}
1189+
/>
1190+
) : null}
10871191
</tr>
10881192
</thead>
10891193
<tbody className="select-none">
@@ -1104,6 +1208,21 @@ function CsvGrid({
11041208
/>
11051209
);
11061210
})}
1211+
{showsRows
1212+
? Array.from({ length: fill.rows }, (_, index) => (
1213+
<tr key={`fill-${index}`} aria-hidden>
1214+
<th className="sticky left-0 z-2 h-7 border-r border-b border-border bg-card px-2 text-right font-normal text-muted-foreground tabular-nums">
1215+
{(order.length + 2 + index).toLocaleString()}
1216+
</th>
1217+
{sheet.columns.map((_, column) => (
1218+
<td
1219+
key={column}
1220+
className="border-r border-b border-border"
1221+
/>
1222+
))}
1223+
</tr>
1224+
))
1225+
: null}
11071226
</tbody>
11081227
</table>
11091228
<div

0 commit comments

Comments
 (0)