Takazudo Modular Styleguide

Notify/BaseDialog

Always Open
Default
Long Content
With Form Content

Component Source

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;

Story Source

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>
);