100 lines
2.7 KiB
TypeScript
100 lines
2.7 KiB
TypeScript
'use client';
|
|
|
|
import { Button } from '@/components/actions/Button';
|
|
import { useScrollLock } from '@/components/foundation/client-hooks';
|
|
import { classNames } from '@/lib/components/classNames';
|
|
import { useEffect, useId, useRef, type ReactNode, type RefObject } from 'react';
|
|
import styles from './Dialog.module.css';
|
|
|
|
interface DialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
title: string;
|
|
description?: string;
|
|
children: ReactNode;
|
|
closeLabel: string;
|
|
initialFocusRef?: RefObject<HTMLElement | null>;
|
|
returnFocusRef?: RefObject<HTMLElement | null>;
|
|
variant?: 'modal' | 'drawer';
|
|
dismissible?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function Dialog({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
description,
|
|
children,
|
|
closeLabel,
|
|
initialFocusRef,
|
|
returnFocusRef,
|
|
variant = 'modal',
|
|
dismissible = true,
|
|
className,
|
|
}: DialogProps) {
|
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
const closeRef = useRef<HTMLButtonElement>(null);
|
|
const titleId = useId();
|
|
const descriptionId = useId();
|
|
useScrollLock(open);
|
|
|
|
useEffect(() => {
|
|
const dialog = dialogRef.current;
|
|
if (!dialog) return;
|
|
if (open && !dialog.open) {
|
|
dialog.showModal();
|
|
window.requestAnimationFrame(() => (initialFocusRef?.current ?? closeRef.current)?.focus());
|
|
} else if (!open && dialog.open) {
|
|
dialog.close();
|
|
}
|
|
}, [initialFocusRef, open]);
|
|
|
|
const close = () => {
|
|
dialogRef.current?.close();
|
|
onOpenChange(false);
|
|
window.requestAnimationFrame(() => returnFocusRef?.current?.focus());
|
|
};
|
|
|
|
return (
|
|
<dialog
|
|
ref={dialogRef}
|
|
className={classNames(styles.dialog, styles[variant], className)}
|
|
aria-labelledby={titleId}
|
|
aria-describedby={description ? descriptionId : undefined}
|
|
onCancel={(event) => {
|
|
if (!dismissible) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
close();
|
|
}}
|
|
onClose={() => onOpenChange(false)}
|
|
onClick={(event) => {
|
|
if (dismissible && event.target === dialogRef.current) close();
|
|
}}
|
|
>
|
|
<div className={styles.panel}>
|
|
<header className={styles.header}>
|
|
<div>
|
|
<h2 id={titleId}>{title}</h2>
|
|
{description ? <p id={descriptionId}>{description}</p> : null}
|
|
</div>
|
|
{dismissible ? (
|
|
<Button
|
|
ref={closeRef}
|
|
intent="ghost"
|
|
size="small"
|
|
icon="close"
|
|
aria-label={closeLabel}
|
|
onClick={close}
|
|
/>
|
|
) : null}
|
|
</header>
|
|
<div className={styles.content}>{children}</div>
|
|
</div>
|
|
</dialog>
|
|
);
|
|
}
|