Repository Public
1// A read-only grid for CSV files and workbook sheets: sticky header and row numbers, fixed row height so that only2// the rows on screen exist in the DOM, and the full content of the selected cell underneath.3import { useEffect, useMemo, useState } from 'react';4import { useT } from '../i18n.ts';5import { cx } from '../ui.tsx';6import { useWindow } from './shared.tsx';78const ROW = 32;9/** A, B … Z, AA, AB … */10export const columnLetter = (i: number): string => { let s = ''; for (let n = i + 1; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s; return s; };1112export function Grid({ header, rows, columns, firstRowNumber = 1, label }: { header: string[]; rows: string[][]; columns: number; firstRowNumber?: number; label: string }) {13 const t = useT();14 const { ref, start, end, before, after } = useWindow(rows.length, ROW);15 const [sel, setSel] = useState<{ r: number; c: number } | null>(null);16 useEffect(() => { setSel(null); if (ref.current) { ref.current.scrollTop = 0; ref.current.scrollLeft = 0; } }, [rows, ref]);17 // widths from a sample of the content, so that the layout does not move while scrolling18 const widths = useMemo(() => {19 const w: number[] = [];20 for (let c = 0; c < columns; c++) {21 let max = (header[c] ?? '').length;22 for (let r = 0; r < rows.length && r < 300; r++) { const n = rows[r]![c]?.length ?? 0; if (n > max) max = n; }23 w.push(Math.max(72, Math.min(320, Math.round(max * 7.6 + 22))));24 }25 return w;26 }, [header, rows, columns]);27 const gutter = Math.max(44, String(firstRowNumber + rows.length).length * 9 + 20);28 const total = gutter + widths.reduce((a, b) => a + b, 0);29 const selected = sel ? rows[sel.r]?.[sel.c] ?? '' : null;30 const head = 'sticky top-0 z-10 h-8 border-b border-r border-line bg-hover px-2 text-left text-xs font-semibold text-ink-2';31 return (32 <div className="flex min-h-0 flex-1 flex-col">33 <div ref={ref} tabIndex={0} role="region" aria-label={label} data-testid="grid" className="min-h-0 flex-1 overflow-auto bg-surface outline-none focus-visible:ring-2 focus-visible:ring-accent/40">34 <table className="table-fixed border-separate border-spacing-0 text-sm" style={{ width: total }}>35 <colgroup><col style={{ width: gutter }} />{widths.map((w, i) => <col key={i} style={{ width: w }} />)}</colgroup>36 <thead>37 <tr>38 <th className={cx(head, 'left-0 z-20 text-center')} aria-label={t('preview.rowNumber')} />39 {widths.map((_, c) => <th key={c} scope="col" title={header[c]} className={cx(head, 'truncate')}>{header[c] ?? ''}</th>)}40 </tr>41 </thead>42 <tbody>43 {before ? <tr aria-hidden="true" style={{ height: before }}><td colSpan={columns + 1} /></tr> : null}44 {rows.slice(start, end).map((row, i) => {45 const r = start + i;46 return (47 <tr key={r} style={{ height: ROW }}>48 <th scope="row" className="sticky left-0 z-[5] border-b border-r border-line bg-hover px-2 text-center text-xs font-normal tabular-nums text-ink-3">{firstRowNumber + r}</th>49 {widths.map((_, c) => {50 const v = row[c] ?? '';51 const on = sel?.r === r && sel.c === c;52 return <td key={c} onClick={() => setSel(on ? null : { r, c })} aria-selected={on || undefined}53 className={cx('cursor-default truncate border-b border-r border-line px-2 text-ink', on && 'bg-accent-soft outline outline-2 -outline-offset-2 outline-accent')}>{v}</td>;54 })}55 </tr>56 );57 })}58 {after ? <tr aria-hidden="true" style={{ height: after }}><td colSpan={columns + 1} /></tr> : null}59 </tbody>60 </table>61 </div>62 {sel && selected !== null ? (63 <div className="flex max-h-28 shrink-0 gap-3 overflow-y-auto border-t border-line bg-raised px-4 py-2 text-sm" data-testid="grid-cell">64 <span className="shrink-0 font-mono text-xs leading-6 text-ink-3">{columnLetter(sel.c)}{firstRowNumber + sel.r}</span>65 <span className="min-w-0 whitespace-pre-wrap break-words text-ink">{selected === '' ? <span className="text-ink-3">{t('preview.emptyCell')}</span> : selected}</span>66 </div>67 ) : null}68 </div>69 );70}71