SPB Git

Repository Public

Grid.tsx 4.3 KB 71 lines tsx

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
Files main
Menu

Theme