import { useEffect, useRef, type ReactNode } from 'react'; import { editableValue, formatCell, isComputed, isNumericColumn, type CellValue, type ColumnMeta, type Row, type TableMeta, } from '@wellformed/shared'; import { LINK_OPTION_LIMIT } from '../api/hooks'; import { useI18n } from '../i18n'; import { KeyIcon, selectColors } from './Icons'; import { LinkPicker } from './LinkPicker'; export interface EditorState { rowId: string; col: string; draft: string; /** The edit was started by typing, so the draft is the first character. */ seeded?: boolean; /** A link editor opens searching for this — the typing that opened it. */ search?: string; } /** Where the cursor goes once a cell has been committed from the keyboard. */ export type Move = 'down' | 'right' | 'left'; interface CellProps { row: Row; col: ColumnMeta; selected: boolean; /** Part of the block selection: a chosen row and a chosen column overlap here. */ inBlock: boolean; editing: EditorState | null; /** Link targets, so a link cell can offer the rows it may point at. */ linkOptions: Row[] | undefined; linkTable: TableMeta | undefined; /** The workspace's currency, for the columns that hold money. */ currency: string; error: string | undefined; readOnly: boolean; /** The row-label column stays visible while the grid scrolls sideways. */ frozen: boolean; onSelect(): void; onBeginEdit(seed?: string): void; /** A double-click that read-only swallowed — the grid may want to say why. */ onRefused?(): void; onDraft(value: string): void; onCommit(value: string, move?: Move): void; /** `byKey` when Escape closed it, so the grid can take the keys back. */ onCancel(byKey: boolean): void; onContextMenu(event: React.MouseEvent): void; /** Follow a link cell to the row it points at. */ onOpenLink(rowId: string): void; } export function GridCell({ row, col, selected, inBlock, editing, linkOptions, linkTable, currency, error, readOnly, frozen, onSelect, onBeginEdit, onRefused, onDraft, onCommit, onCancel, onContextMenu, onOpenLink, }: CellProps): ReactNode { const computed = isComputed(col.type); const numeric = isNumericColumn(col); const value: CellValue = row.values[col.name] ?? null; const label = row.labels[col.name]; const isEditing = editing?.rowId === row.id && editing.col === col.name; // A checkbox has two states and no text, so it has nothing to open an editor // for: the box itself is the control. const toggle = col.type === 'checkbox' && !computed && !readOnly ? () => onCommit(value ? 'false' : 'true') : undefined; const className = [ 'cell', numeric ? 'num' : '', col.type === 'date' ? 'datev' : '', computed ? 'computed' : '', computed && error ? 'errored' : '', ] .filter(Boolean) .join(' '); return (
{ if (computed || toggle) return; if (readOnly) onRefused?.(); else onBeginEdit(); }} onContextMenu={onContextMenu} role="gridcell" aria-selected={selected || inBlock} aria-readonly={computed || readOnly} title={computed && error ? error : undefined} > {isEditing && !toggle ? ( ) : ( )}
); } function CellValueView({ col, value, label, currency, error, onToggle, onOpenLink, }: { onOpenLink(rowId: string): void; /** Set for a writable checkbox: the box is its own editor. */ onToggle: (() => void) | undefined; col: ColumnMeta; value: CellValue; label: string | null | undefined; currency: string; error: string | undefined; }): ReactNode { const { t, locale } = useI18n(); if (isComputed(col.type) && error) return {t('grid.cellError')}; if (col.type === 'link') { if (!label) return <>{' '}; return ( // A relation you can only look at is half a relation. Click follows it; // the surrounding cell still selects on mousedown as it always did. ); } if (col.type === 'checkbox') { return ( onToggle?.()} /> ); } if (col.type === 'select') { const text = value === null || value === '' ? '' : String(value); if (!text) return <>{' '}; const [bg, fg] = selectColors(text); return ( {text} ); } const text = formatCell(value, col, { label, currency, locale }); return {text || ' '}; } function CellEditor({ col, draft, seeded, search, linkOptions, linkTable, onDraft, onCommit, onCancel, }: { col: ColumnMeta; draft: string; seeded: boolean; /** What a link picker opens searching for. */ search: string; linkOptions: Row[] | undefined; linkTable: TableMeta | undefined; onDraft(value: string): void; onCommit(value: string, move?: Move): void; onCancel(byKey: boolean): void; }): ReactNode { const inputRef = useRef(null); const selectRef = useRef(null); /** * Set once a key has decided what happens to this edit. * * Handing the keys back to the grid blurs this input, and blur means "commit * what is in the box" — which would write the value a second time after * Enter, and write the abandoned typing after Escape. */ const settled = useRef(false); /** Set once a key has moved a dropdown's highlight, so change means "browsing". */ const browsing = useRef(false); /** What the cell held when the editor opened, to tell a pick from a no-op. */ const initial = useRef(draft); useEffect(() => { const input = inputRef.current; input?.focus(); // Selecting the text is right when the edit opened on what is already // there. It is wrong when the edit was started by typing: the seed is that // first character, and selecting it means the next keystroke eats it. if (input && seeded && input.type === 'text') { input.setSelectionRange(input.value.length, input.value.length); } else { input?.select(); } selectRef.current?.focus(); }, [seeded]); if (col.type === 'link') { return ( ({ value: r.id, label: linkTable ? editableValue(r.values[linkTable.display] ?? null) || r.id : r.id, }))} capped={(linkOptions?.length ?? 0) >= LINK_OPTION_LIMIT} onCommit={onCommit} onCancel={onCancel} /> ); } if (col.type === 'select') { const options = (col.options ?? []).map((o) => ({ value: o, label: o })); return ( ); } return ( onDraft(e.target.value)} onBlur={() => { if (!settled.current) onCommit(draft); }} onKeyDown={(e) => { // The editor owns these keys; the grid's navigation must not also act, // so where the cursor goes next travels with the value instead. if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); settled.current = true; onCommit(draft, 'down'); } else if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); settled.current = true; onCancel(true); } else if (e.key === 'Tab') { e.stopPropagation(); e.preventDefault(); settled.current = true; onCommit(draft, e.shiftKey ? 'left' : 'right'); } else { e.stopPropagation(); } }} /> ); }