Notify/BaseDialog
Always Open
Default
Long Content
With Form Content
base-dialog.tsx
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;
/** Accessible label for the close (X) button. Defaults to JA. */
closeLabel?: 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,
closeLabel = '閉じる',
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={closeLabel}
>
<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;
base-dialog.stories.tsx
import { useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { BaseDialog } from './base-dialog';
import { ActionButton } from '@/components/shared/action-button';
import { DialogStoryContainer } from '@/.storybook/dialog-story-container';
import type { ControlsMap } from '../../sub-packages/styleguide-v2/src/data/control-types';
/**
* BaseDialog Component Stories
*
* The foundational dialog component used by NotifyMeDialog and ReservationDialog.
* Features:
* - Portal rendering (embedded in story container to avoid blocking styleguide UI)
* - Focus trap for accessibility
* - CSS fade/scale animations
* - Escape key to close
*/
export const meta = {
title: 'Notify/BaseDialog',
};
export const controls: ControlsMap = {
title: { type: 'text', default: '基本ダイアログ' },
};
/**
* Interactive wrapper to demonstrate dialog open/close behavior.
* Uses embedded portal container to keep dialog within the story area.
*/
const DialogWrapper = ({
title,
children,
buttonLabel = 'ダイアログを開く',
}: {
title: string;
children: ComponentChildren;
buttonLabel?: string;
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<DialogStoryContainer>
{(portalContainer) => (
<div className="p-hgap-sm">
<ActionButton onClick={() => setIsOpen(true)}>{buttonLabel}</ActionButton>
<BaseDialog
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={title}
portalContainer={portalContainer}
>
{children}
</BaseDialog>
</div>
)}
</DialogStoryContainer>
);
};
/**
* Default State
*
* Basic dialog with simple content.
*/
export const Default = {
args: { title: '基本ダイアログ' },
render: ({ title }: { title: string }) => (
<DialogWrapper title={title}>
<p className="text-zd-white">
これはベースダイアログコンポーネントのデモです。
<br />
ボタンをクリックしてダイアログを開閉できます。
</p>
</DialogWrapper>
),
};
/**
* With Form Content
*
* Dialog containing form elements to demonstrate form accessibility.
*/
export const WithFormContent = () => (
<DialogWrapper title="フォーム付きダイアログ" buttonLabel="フォームダイアログを開く">
<form className="flex flex-col gap-vgap-sm">
<div>
<label htmlFor="demo-email" className="block text-zd-white pb-vgap-2xs">
メールアドレス
</label>
<input
id="demo-email"
type="email"
className="w-full px-hgap-xs py-vgap-xs text-zd-black bg-zd-white"
placeholder="example@example.com"
/>
</div>
<button
type="submit"
className="px-hgap-md py-vgap-xs bg-zd-link text-zd-white rounded"
onClick={(e) => e.preventDefault()}
>
送信
</button>
</form>
</DialogWrapper>
);
/**
* Long Content
*
* Dialog with scrollable content to test overflow behavior.
*/
export const LongContent = () => (
<DialogWrapper title="長いコンテンツ" buttonLabel="長いコンテンツダイアログを開く">
<div className="text-zd-white max-h-[300px] overflow-y-auto">
<p className="pb-vgap-sm">
これは長いコンテンツを含むダイアログのデモです。コンテンツが長い場合、スクロールが可能です。
</p>
{Array.from({ length: 10 }).map((_, i) => (
<p key={i} className="pb-vgap-sm">
段落 {i + 1}:
モジュラーシンセサイザーは、独立したモジュールを組み合わせて音を作り出す電子楽器です。
</p>
))}
</div>
</DialogWrapper>
);
/**
* Always Open (embedded for visual testing)
*
* Dialog that starts in open state, rendered within the story container.
*/
export const AlwaysOpen = () => (
<DialogStoryContainer>
{(portalContainer) => (
<BaseDialog
isOpen={true}
onClose={() => {}}
title="常に開いたダイアログ"
portalContainer={portalContainer}
>
<p className="text-zd-white">
このダイアログはスクリーンショットテスト用に常に開いた状態で表示されます。
</p>
</BaseDialog>
)}
</DialogStoryContainer>
);