Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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),
};
}