import type { ComponentChildren } from 'preact';
import { useEffect, useState, useId, useCallback } from 'preact/hooks';
import { createPortal } from 'preact/compat';
import { useLockBodyScroll } from '@/src/hooks/use-lock-body-scroll';
import { useFocusTrap } from '@/src/hooks/use-focus-trap';
interface BaseDialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ComponentChildren;
/** Optional: aria-describedby for screen readers */
ariaDescribedBy?: string;
/**
* Optional: custom portal container element.
* When provided, the dialog renders into this container using absolute positioning
* instead of document.body with fixed positioning. Body scroll lock is also skipped.
* Useful for Storybook stories to keep the dialog within the story area.
*/
portalContainer?: HTMLElement | null;
}
/**
* Custom hook for delayed unmount animation
* Keeps the component mounted during the exit animation
*/
function useDelayedUnmount(isOpen: boolean, delayMs: number = 200): boolean {
const [shouldRender, setShouldRender] = useState(isOpen);
useEffect(() => {
let openTimer: ReturnType<typeof setTimeout> | undefined;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
if (isOpen) {
// Use setTimeout to make the setState call asynchronous
openTimer = setTimeout(() => {
setShouldRender(true);
}, 0);
} else {
closeTimer = setTimeout(() => {
setShouldRender(false);
}, delayMs);
}
return () => {
if (openTimer) clearTimeout(openTimer);
if (closeTimer) clearTimeout(closeTimer);
};
}, [isOpen, delayMs]);
return shouldRender;
}
/**
* Custom hook for animation state
* Returns true after a frame delay when isOpen becomes true
*/
function useAnimationState(isOpen: boolean): boolean {
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
let outerRafId: number | undefined;
let innerRafId: number | undefined;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
if (isOpen) {
// Use double RAF to start animation after render
outerRafId = requestAnimationFrame(() => {
innerRafId = requestAnimationFrame(() => {
setIsAnimating(true);
});
});
} else {
// Use setTimeout to make the setState call asynchronous
closeTimer = setTimeout(() => {
setIsAnimating(false);
}, 0);
}
return () => {
if (outerRafId) cancelAnimationFrame(outerRafId);
if (innerRafId) cancelAnimationFrame(innerRafId);
if (closeTimer) clearTimeout(closeTimer);
};
}, [isOpen]);
return isAnimating;
}
/**
* Base Dialog Component
*
* A reusable modal dialog with:
* - Portal rendering to document.body
* - Focus trap for accessibility
* - Body scroll lock
* - CSS animations (fade/scale)
* - Keyboard navigation (Escape to close)
* - ARIA attributes
*/
export function BaseDialog({
isOpen,
onClose,
title,
children,
ariaDescribedBy,
portalContainer: portalContainerProp,
}: BaseDialogProps) {
const uniqueId = useId();
const titleId = `dialog-title${uniqueId}`;
const shouldRender = useDelayedUnmount(isOpen, 200);
const isAnimating = useAnimationState(isOpen);
// Determine if we're in embedded mode (custom portal container provided)
const isEmbedded = portalContainerProp !== undefined;
// Lock body scroll when dialog is open (skip in embedded mode)
useLockBodyScroll(isEmbedded ? false : isOpen);
// Focus trap for accessibility
// Note: autoFocus is disabled here - individual form inputs handle their own autoFocus
const { containerRef } = useFocusTrap({
isActive: isOpen,
autoFocus: false,
returnFocusOnDeactivate: true,
onClose,
});
// Get portal container - use custom container if provided, otherwise document.body
const getPortalContainer = useCallback(() => {
if (portalContainerProp) return portalContainerProp;
if (typeof document !== 'undefined') {
return document.body;
}
return null;
}, [portalContainerProp]);
// Don't render during SSR or when not needed
if (!shouldRender) return null;
const resolvedPortalContainer = getPortalContainer();
if (!resolvedPortalContainer) return null;
// Use absolute positioning for embedded mode, fixed for full-screen mode
const positionClass = isEmbedded ? 'absolute' : 'fixed';
// Render dialog in portal to ensure proper z-index layering
return createPortal(
<>
{/* Backdrop overlay */}
<div
className={`${positionClass} inset-0 z-[60] bg-black/70 transition-opacity duration-200 ease-out ${isAnimating ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
aria-hidden="true"
/>
{/* Dialog container - centered */}
<div
ref={containerRef}
className={`${positionClass} inset-0 z-[70] flex items-center justify-center p-hgap-sm`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={ariaDescribedBy}
>
{/* Dialog panel */}
<div
className={`relative w-full max-w-[580px] bg-zd-gray2 border-2 border-zd-white rounded-md shadow-xl transition-all duration-200 ease-out ${isAnimating ? 'opacity-100 scale-100' : 'opacity-0 scale-95'}`}
>
{/* Header */}
<div
className={
'flex items-center justify-between border-b border-zd-gray px-hgap-sm md:px-hgap-md py-vgap-sm'
}
>
<h2 id={titleId} className="text-lg font-bold text-zd-white">
{title}
</h2>
<button
type="button"
onClick={onClose}
className={
'text-zd-white hover:text-zd-link transition-colors w-[32px] h-[32px] flex items-center justify-center'
}
aria-label="閉じる"
>
<CloseIcon />
</button>
</div>
{/* Content */}
<div className="px-hgap-sm md:px-hgap-md py-vgap-md">{children}</div>
</div>
</div>
</>,
resolvedPortalContainer,
);
}
/**
* Close icon (X) for dialog header
*/
const CloseIcon = () => (
<svg
className="w-[20px] h-[20px]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
export default BaseDialog;