Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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 (
<td
className={
[selected ? 'sel' : '', inBlock ? 'insel' : '', frozen ? 'frozen' : '']
.filter(Boolean)
.join(' ') || undefined
}
data-col={col.name}
>
<div
className={className}
onMouseDown={onSelect}
onDoubleClick={() => {
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 ? (
<CellEditor
col={col}
draft={editing.draft}
seeded={Boolean(editing.seeded)}
search={editing.search ?? ''}
linkOptions={linkOptions}
linkTable={linkTable}
onDraft={onDraft}
onCommit={onCommit}
onCancel={onCancel}
/>
) : (
<CellValueView
col={col}
value={value}
label={label}
currency={currency}
error={error}
onToggle={toggle}
onOpenLink={onOpenLink}
/>
)}
</div>
</td>
);
}
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 <span>{t('grid.cellError')}</span>;
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.
<button
type="button"
className="chip linked"
aria-haspopup="dialog"
title={t('grid.openLink', label)}
onClick={(e) => {
e.stopPropagation();
if (value !== null && value !== undefined) onOpenLink(String(value));
}}
>
<KeyIcon />
<span>{label}</span>
</button>
);
}
if (col.type === 'checkbox') {
return (
<input
type="checkbox"
checked={Boolean(value)}
readOnly={!onToggle}
tabIndex={-1}
aria-label={col.label}
onChange={() => onToggle?.()}
/>
);
}
if (col.type === 'select') {
const text = value === null || value === '' ? '' : String(value);
if (!text) return <>{' '}</>;
const [bg, fg] = selectColors(text);
return (
<span className="pill" style={{ background: bg, color: fg }}>
{text}
</span>
);
}
const text = formatCell(value, col, { label, currency, locale });
return <span>{text || ' '}</span>;
}
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<HTMLInputElement>(null);
const selectRef = useRef<HTMLSelectElement>(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 (
<LinkPicker
colName={col.label}
draft={draft}
initialSearch={search}
options={(linkOptions ?? []).map((r) => ({
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 (
<select
ref={selectRef}
value={draft}
aria-label={col.label}
/**
* A focused `<select>` whose list is shut changes value on every arrow
* key, so committing on change meant the first ArrowDown wrote an
* option you were only passing over. Arrows and letters therefore move
* a draft, and Enter is what writes it. A click still commits at once:
* a mouse pick raises change with no key before it.
*/
onChange={(e) => {
if (browsing.current) {
onDraft(e.target.value);
return;
}
settled.current = true;
onCommit(e.target.value);
}}
onBlur={() => {
if (settled.current) return;
// Picked with the mouse and then clicked away: keep the pick, the
// way clicking away from a half-typed cell keeps the typing.
if (draft !== initial.current) onCommit(draft);
else onCancel(false);
}}
onKeyDown={(e) => {
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();
browsing.current = true;
}
}}
>
<option value="">—</option>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
return (
<input
ref={inputRef}
type={col.type === 'date' ? 'date' : 'text'}
inputMode={col.type === 'number' || col.type === 'currency' ? 'decimal' : undefined}
value={draft}
aria-label={col.label}
onChange={(e) => 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();
}
}}
/>
);
}