186 lines
5.1 KiB
TypeScript
186 lines
5.1 KiB
TypeScript
'use client';
|
|
|
|
import React, {
|
|
useEffect,
|
|
useRef,
|
|
type ReactNode,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
} from 'react';
|
|
import { Box } from './Box';
|
|
import { Text } from './Text';
|
|
import { Heading } from './Heading';
|
|
import { Button } from './Button';
|
|
|
|
interface ModalProps {
|
|
title: string;
|
|
description?: string;
|
|
children?: ReactNode;
|
|
primaryActionLabel?: string;
|
|
secondaryActionLabel?: string;
|
|
onPrimaryAction?: () => void | Promise<void>;
|
|
onSecondaryAction?: () => void;
|
|
onOpenChange?: (open: boolean) => void;
|
|
isOpen: boolean;
|
|
}
|
|
|
|
export function Modal({
|
|
title,
|
|
description,
|
|
children,
|
|
primaryActionLabel,
|
|
secondaryActionLabel,
|
|
onPrimaryAction,
|
|
onSecondaryAction,
|
|
onOpenChange,
|
|
isOpen,
|
|
}: ModalProps) {
|
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
|
const previouslyFocusedElementRef = useRef<Element | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
previouslyFocusedElementRef.current = document.activeElement;
|
|
const focusable = getFirstFocusable(dialogRef.current);
|
|
if (focusable) {
|
|
focusable.focus();
|
|
} else if (dialogRef.current) {
|
|
dialogRef.current.focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) {
|
|
previouslyFocusedElementRef.current.focus();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
if (event.key === 'Escape') {
|
|
if (onOpenChange) {
|
|
onOpenChange(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Tab') {
|
|
const focusable = getFocusableElements(dialogRef.current);
|
|
if (focusable.length === 0) return;
|
|
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1] ?? first;
|
|
|
|
if (!first || !last) {
|
|
return;
|
|
}
|
|
|
|
if (!event.shiftKey && document.activeElement === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
} else if (event.shiftKey && document.activeElement === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
if (event.target === event.currentTarget && onOpenChange) {
|
|
onOpenChange(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.6)', padding: '0 1rem' }}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-title"
|
|
aria-describedby={description ? 'modal-description' : undefined}
|
|
onKeyDown={handleKeyDown}
|
|
onClick={handleBackdropClick}
|
|
>
|
|
<Box
|
|
ref={dialogRef}
|
|
style={{ width: '100%', maxWidth: '28rem', borderRadius: '1rem', backgroundColor: '#0f1115', border: '1px solid #262626', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)', outline: 'none' }}
|
|
tabIndex={-1}
|
|
>
|
|
<Box p={6} style={{ borderBottom: '1px solid rgba(38, 38, 38, 0.8)' }}>
|
|
<Heading level={2} id="modal-title">{title}</Heading>
|
|
{description && (
|
|
<Text
|
|
id="modal-description"
|
|
size="sm"
|
|
color="text-gray-400"
|
|
block
|
|
mt={2}
|
|
>
|
|
{description}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
|
|
<Box p={6}>
|
|
<Text size="sm" color="text-gray-100">{children}</Text>
|
|
</Box>
|
|
|
|
{(primaryActionLabel || secondaryActionLabel) && (
|
|
<Box p={6} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.8)', display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
|
{secondaryActionLabel && (
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
onSecondaryAction?.();
|
|
onOpenChange?.(false);
|
|
}}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
{secondaryActionLabel}
|
|
</Button>
|
|
)}
|
|
{primaryActionLabel && (
|
|
<Button
|
|
type="button"
|
|
onClick={async () => {
|
|
if (onPrimaryAction) {
|
|
await onPrimaryAction();
|
|
}
|
|
}}
|
|
variant="primary"
|
|
size="sm"
|
|
>
|
|
{primaryActionLabel}
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function getFocusableElements(root: HTMLElement | null): HTMLElement[] {
|
|
if (!root) return [];
|
|
const selectors = [
|
|
'a[href]',
|
|
'button:not([disabled])',
|
|
'textarea:not([disabled])',
|
|
'input:not([disabled])',
|
|
'select:not([disabled])',
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
];
|
|
const nodes = Array.from(
|
|
root.querySelectorAll<HTMLElement>(selectors.join(',')),
|
|
);
|
|
return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'));
|
|
}
|
|
|
|
function getFirstFocusable(root: HTMLElement | null): HTMLElement | null {
|
|
const elements = getFocusableElements(root);
|
|
return elements[0] ?? null;
|
|
}
|