mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
90 lines
2.7 KiB
TypeScript
90 lines
2.7 KiB
TypeScript
import { type ReactNode, useEffect, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
|
import { useT } from '../../lib/i18n/I18nContext';
|
|
import Button from './Button';
|
|
import { CloseIcon } from './icons';
|
|
|
|
interface ModalShellProps {
|
|
children: ReactNode;
|
|
onClose: () => void;
|
|
title: ReactNode;
|
|
titleId: string;
|
|
subtitle?: ReactNode;
|
|
icon?: ReactNode;
|
|
maxWidthClassName?: string;
|
|
contentClassName?: string;
|
|
labelledBy?: string;
|
|
}
|
|
|
|
export function ModalShell({
|
|
children,
|
|
onClose,
|
|
title,
|
|
titleId,
|
|
subtitle,
|
|
icon,
|
|
maxWidthClassName = 'max-w-md',
|
|
contentClassName = 'px-5 py-4',
|
|
labelledBy,
|
|
}: ModalShellProps) {
|
|
const { t } = useT();
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEscapeKey(onClose);
|
|
|
|
useEffect(() => {
|
|
const previousFocus = document.activeElement as HTMLElement | null;
|
|
dialogRef.current?.focus();
|
|
return () => previousFocus?.focus?.();
|
|
}, []);
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
onClick={event => {
|
|
if (event.target === event.currentTarget) onClose();
|
|
}}>
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={labelledBy ?? titleId}
|
|
tabIndex={-1}
|
|
className={`w-full ${maxWidthClassName} mx-4 rounded-2xl bg-white dark:bg-neutral-900 shadow-xl overflow-hidden animate-fade-up focus:outline-none`}
|
|
onClick={event => event.stopPropagation()}>
|
|
<div className="flex items-center justify-between border-b border-stone-100 dark:border-neutral-800 px-5 py-4">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
{icon ? (
|
|
<div className="w-9 h-9 rounded-xl bg-primary-50 flex items-center justify-center text-primary-600 flex-shrink-0">
|
|
{icon}
|
|
</div>
|
|
) : null}
|
|
<div className="min-w-0">
|
|
<h2
|
|
id={titleId}
|
|
className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
|
{title}
|
|
</h2>
|
|
{subtitle ? (
|
|
<p className="text-xs text-stone-500 dark:text-neutral-400">{subtitle}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
iconOnly
|
|
variant="tertiary"
|
|
size="sm"
|
|
aria-label={t('common.close')}
|
|
onClick={onClose}>
|
|
<CloseIcon className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<div className={contentClassName}>{children}</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|