Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { useI18n } from '../i18n';
interface ModalProps {
title: string;
onClose(): void;
children: ReactNode;
footer?: ReactNode;
wide?: boolean;
}
/**
* A dialog that behaves like one: Escape closes it, focus starts inside and
* cannot Tab out, and it returns focus to whatever opened it.
*/
export function Modal({ title, onClose, children, footer, wide }: ModalProps): ReactNode {
const ref = useRef<HTMLDivElement>(null);
const titleId = useId();
const returnFocusTo = useRef<Element | null>(null);
useEffect(() => {
returnFocusTo.current = document.activeElement;
const first = ref.current?.querySelector<HTMLElement>(
'input:not([type=hidden]), select, textarea, button',
);
first?.focus();
return () => {
if (returnFocusTo.current instanceof HTMLElement) returnFocusTo.current.focus();
};
}, []);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
return;
}
if (event.key !== 'Tab') return;
const focusable = ref.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (!focusable?.length) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [onClose]);
return (
<div
className="scrim"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={wide ? 'modal wide' : 'modal'}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
ref={ref}
>
<h2 id={titleId}>{title}</h2>
{children}
{footer && <div className="mf">{footer}</div>}
</div>
</div>
);
}
interface ConfirmProps {
title: string;
message: ReactNode;
confirmLabel?: string;
destructive?: boolean;
busy?: boolean;
onConfirm(): void;
onCancel(): void;
}
export function ConfirmDialog({
title,
message,
confirmLabel,
destructive,
busy,
onConfirm,
onCancel,
}: ConfirmProps): ReactNode {
const { t } = useI18n();
return (
<Modal
title={title}
onClose={onCancel}
footer={
<>
<button className="btn" onClick={onCancel} disabled={busy}>
{t('common.cancel')}
</button>
<button
className={destructive ? 'btn destructive' : 'btn primary'}
onClick={onConfirm}
disabled={busy}
>
{busy ? t('common.working') : (confirmLabel ?? t('common.confirm'))}
</button>
</>
}
>
<div className="mb">{message}</div>
</Modal>
);
}