Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

11.0 kB Ruw bestand
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();
        }
      }}
    />
  );
}