Spreadsheet-UX op een echte relationele engine

main

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

7.2 kB Ruw bestand
import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from 'react';
import { useI18n } from '../i18n';
import type { Move } from './GridCell';

export interface LinkOption {
  value: string;
  label: string;
}

/**
 * The foreign-key editor: a search field in the cell, the rows the column may
 * point at in a panel under it.
 *
 * A custom element rather than a `<select>`, because a select can only offer a
 * list — and five hundred rows without a search field is a scroll, not a
 * choice. The keys keep the grid's contract: Enter writes the highlighted row
 * and moves down, Tab carries on sideways, Escape hands the keys back, and a
 * click writes at once. Tab writes the highlight only once something has been
 * typed or browsed to; untouched, it keeps what the cell already held, so
 * tabbing across a row changes nothing it does not mean to.
 */
export function LinkPicker({
  colName,
  draft,
  initialSearch,
  options,
  capped,
  onCommit,
  onCancel,
}: {
  colName: string;
  /** The row id the cell holds now — what an untouched Tab keeps. */
  draft: string;
  /** Typing on the selected cell opens the picker already searching for it. */
  initialSearch: string;
  options: LinkOption[];
  /** True when the option list hit its fetch cap, so the search is partial. */
  capped: boolean;
  onCommit(value: string, move?: Move): void;
  onCancel(byKey: boolean): void;
}): ReactNode {
  const { t, locale } = useI18n();
  const [search, setSearch] = useState(initialSearch);
  const inputRef = useRef<HTMLInputElement>(null);
  const wrapRef = useRef<HTMLDivElement>(null);
  const activeRef = useRef<HTMLButtonElement>(null);
  /** See CellEditor: blur must not write (or cancel) twice after a key has. */
  const settled = useRef(false);
  /** Whether anything was typed or browsed to, so Tab knows what it keeps. */
  const touched = useRef(false);
  const listId = useId();

  const needle = search.trim().toLowerCase();
  const matches = needle
    ? options.filter((o) => o.label.toLowerCase().includes(needle))
    : options;
  // Clearing the cell is offered as the first row, but only while nothing has
  // been typed: a search means looking for a row, not for the absence of one.
  const entries: LinkOption[] = needle ? matches : [{ value: '', label: '—' }, ...matches];

  const [highlight, setHighlight] = useState(() => {
    const at = entries.findIndex((e) => e.value === draft);
    return at >= 0 ? at : 0;
  });

  /**
   * The panel escapes the cell. Grid cells clip their content, so the list is
   * fixed-positioned from the cell's measured place — like the context menus —
   * and re-measured while the grid scrolls under it. It opens upward when the
   * room below the cell is not worth opening into.
   */
  const [pos, setPos] = useState<CSSProperties | null>(null);
  const place = useCallback(() => {
    const rect = wrapRef.current?.closest('td')?.getBoundingClientRect();
    if (!rect) return;
    const style: CSSProperties = {
      left: Math.max(6, Math.min(rect.left, window.innerWidth - 226)),
      minWidth: Math.max(180, rect.width),
    };
    const below = window.innerHeight - rect.bottom;
    if (below < 180 && rect.top > below) style.bottom = window.innerHeight - rect.top + 2;
    else style.top = rect.bottom + 2;
    setPos(style);
  }, []);

  useLayoutEffect(() => {
    place();
    window.addEventListener('scroll', place, true);
    window.addEventListener('resize', place);
    return () => {
      window.removeEventListener('scroll', place, true);
      window.removeEventListener('resize', place);
    };
  }, [place]);

  useEffect(() => {
    const input = inputRef.current;
    input?.focus();
    // The seed is what was just typed; the caret belongs after it.
    input?.setSelectionRange(input.value.length, input.value.length);
  }, []);

  useEffect(() => {
    // Optional-called: jsdom, where the tests run, does not implement it.
    activeRef.current?.scrollIntoView?.({ block: 'nearest' });
  }, [highlight]);

  const commit = (value: string, move?: Move) => {
    settled.current = true;
    onCommit(value, move);
  };

  return (
    <div ref={wrapRef} className="linkpick">
      <input
        ref={inputRef}
        role="combobox"
        aria-label={colName}
        aria-expanded="true"
        aria-controls={listId}
        aria-activedescendant={`${listId}-${highlight}`}
        aria-autocomplete="list"
        placeholder={t('grid.searchPlaceholder')}
        value={search}
        onChange={(e) => {
          touched.current = true;
          setSearch(e.target.value);
          setHighlight(0);
        }}
        onBlur={() => {
          // Nothing here commits on blur: a search is not a value, and every
          // way of picking one has already written it.
          if (!settled.current) onCancel(false);
        }}
        onKeyDown={(e) => {
          if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
            e.preventDefault();
            e.stopPropagation();
            touched.current = true;
            const step = e.key === 'ArrowDown' ? 1 : -1;
            setHighlight((h) => Math.max(0, Math.min(entries.length - 1, h + step)));
          } else if (e.key === 'Enter') {
            e.preventDefault();
            e.stopPropagation();
            commit(entries[highlight]?.value ?? draft, 'down');
          } else if (e.key === 'Tab') {
            e.preventDefault();
            e.stopPropagation();
            const keep = touched.current ? (entries[highlight]?.value ?? draft) : draft;
            commit(keep, e.shiftKey ? 'left' : 'right');
          } else if (e.key === 'Escape') {
            e.preventDefault();
            e.stopPropagation();
            settled.current = true;
            onCancel(true);
          } else {
            e.stopPropagation();
          }
        }}
      />

      {pos && (
        <div
          className="linkpick-panel"
          role="listbox"
          id={listId}
          aria-label={t('grid.linkRows', colName)}
          style={pos}
          // Keep the focus in the search field, so a click cannot blur-cancel
          // the editor before it lands on the row it is picking.
          onMouseDown={(e) => e.preventDefault()}
        >
          {entries.map((entry, i) => (
            <button
              key={entry.value || '∅'}
              type="button"
              id={`${listId}-${i}`}
              role="option"
              aria-selected={entry.value === draft}
              ref={i === highlight ? activeRef : undefined}
              className={[
                'hit',
                i === highlight ? 'active' : '',
                entry.value === '' ? 'clear' : '',
              ]
                .filter(Boolean)
                .join(' ')}
              onClick={() => commit(entry.value)}
              onMouseMove={() => setHighlight(i)}
            >
              {entry.label}
            </button>
          ))}
          {matches.length === 0 && (
            <div className="linkpick-note">{t('grid.noLinkMatches')}</div>
          )}
          {capped && (
            <div className="linkpick-note">{t('grid.linkCapped', options.length, locale)}</div>
          )}
        </div>
      )}
    </div>
  );
}