Spreadsheet-UX op een echte relationele engine

main

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

2.8 kB Ruw bestand
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';

export interface MenuItem {
  label: string;
  onSelect(): void;
  disabled?: boolean;
  danger?: boolean;
  hint?: string;
}

export type MenuEntry = MenuItem | 'separator';

interface MenuProps {
  x: number;
  y: number;
  items: MenuEntry[];
  onClose(): void;
  label: string;
}

/** A pop-up menu that keeps itself on screen and closes on the next click. */
export function Menu({ x, y, items, onClose, label }: MenuProps): ReactNode {
  const ref = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState({ left: x, top: y });

  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const left = Math.max(6, Math.min(x, window.innerWidth - rect.width - 6));
    const top = Math.max(6, Math.min(y, window.innerHeight - rect.height - 6));
    setPos({ left, top });
    el.querySelector<HTMLElement>('button:not([disabled])')?.focus();
  }, [x, y]);

  useEffect(() => {
    const close = () => onClose();
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        e.stopPropagation();
        onClose();
      }
    };
    // `mousedown` rather than `click` so the menu is gone before whatever was
    // underneath it reacts.
    window.addEventListener('mousedown', close);
    window.addEventListener('resize', close);
    window.addEventListener('keydown', onKey, true);
    return () => {
      window.removeEventListener('mousedown', close);
      window.removeEventListener('resize', close);
      window.removeEventListener('keydown', onKey, true);
    };
  }, [onClose]);

  return (
    <div
      className="menu"
      role="menu"
      aria-label={label}
      ref={ref}
      style={pos}
      onMouseDown={(e) => e.stopPropagation()}
    >
      {items.map((item, i) =>
        item === 'separator' ? (
          <hr key={`sep-${i}`} />
        ) : (
          <button
            // Indexed, not labelled: two workspaces may share a name, and a
            // duplicate key would have React reusing the wrong entry.
            key={`${item.label}-${i}`}
            role="menuitem"
            className={item.danger ? 'danger' : undefined}
            disabled={item.disabled}
            onClick={() => {
              item.onSelect();
              onClose();
            }}
          >
            {item.label}
            {item.hint && <span className="kbd">{item.hint}</span>}
          </button>
        ),
      )}
    </div>
  );
}

export function useMenu<T>() {
  const [state, setState] = useState<{ x: number; y: number; payload: T } | null>(null);
  return {
    state,
    open: (event: { clientX: number; clientY: number }, payload: T) =>
      setState({ x: event.clientX, y: event.clientY, payload }),
    close: () => setState(null),
  };
}