mcpField
M
modal-drawer-system

modal-drawer-system

github.com/patricio0312revデザイン・UI63278 回インストール41 週間前に更新
リポジトリ

modal-drawer-system について

modal-drawer-systemは、アクセシブルなモーダル、ドロワー、ボトムシートを作るためのデザインスキルです。ポータル描画、フォーカスやスクロールの管理、キーボード操作に加え、確認・編集・詳細表示のパターンを扱います。

主な機能

  1. 01モーダル、ドロワー、ボトムシートのパターン
  2. 02ポータル描画
  3. 03フォーカストラップと復元
  4. 04キーボード操作とARIA対応
  5. 05ページスクロールのロックと背景クリックでの閉鎖
  6. 06表示・終了アニメーション

活用シーン

  1. 01確認ダイアログの作成
  2. 02モーダル内の編集フォーム構築
  3. 03ドロワーでのレコード詳細表示

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/patricio0312rev/skills --skill modal-drawer-system

インストール後、Claude Code で/modal-drawer-systemを入力して読み込まれたか確認します。

詳細

モーダル&ドロワーシステムジェネレーター

アクセシブルで洗練されたモーダルダイアログとドロワーコンポーネントを作成します。

基本的なワークフロー

  1. 種類を選択:モーダル(中央)、ドロワー(サイド)、ボトムシート
  2. ポータルを設定:DOM 階層の外側にレンダリング
  3. フォーカス管理:フォーカスのトラップと復元
  4. アクセシビリティ:ARIA 属性、キーボードショートカット
  5. アニメーション:滑らかな表示・非表示のトランジション
  6. スクロールロック:開いている間、body のスクロールを防止
  7. バックドロップ:外側のクリックで閉じる

基本のモーダルコンポーネント

"use client";

import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title?: string;
  description?: string;
  children: React.ReactNode;
  size?: "sm" | "md" | "lg" | "xl" | "full";
  closeOnEscape?: boolean;
  closeOnBackdrop?: boolean;
}

export function Modal({
  isOpen,
  onClose,
  title,
  description,
  children,
  size = "md",
  closeOnEscape = true,
  closeOnBackdrop = true,
}: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);

  // ESC key handler
  useEffect(() => {
    if (!isOpen || !closeOnEscape) return;

    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };

    document.addEventListener("keydown", handleEscape);
    return () => document.removeEventListener("keydown", handleEscape);
  }, [isOpen, closeOnEscape, onClose]);

  // Focus trap
  useEffect(() => {
    if (!isOpen) return;

    const focusableElements = modalRef.current?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements?.[0] as HTMLElement;
    const lastElement = focusableElements?.[
      focusableElements.length - 1
    ] as HTMLElement;

    const handleTab = (e: KeyboardEvent) => {
      if (e.key !== "Tab") return;

      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement?.focus();
      } else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement?.focus();
      }
    };

    firstElement?.focus();
    document.addEventListener("keydown", handleTab);
    return () => document.removeEventListener("keydown", handleTab);
  }, [isOpen]);

  // Body scroll lock
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = "hidden";
    } else {
      document.body.style.overflow = "";
    }
    return () => {
      document.body.style.overflow = "";
    };
  }, [isOpen]);

  if (!isOpen) return null;

  const sizeClasses = {
    sm: "max-w-sm",
    md: "max-w-md",
    lg: "max-w-lg",
    xl: "max-w-xl",
    full: "max-w-full mx-4",
  };

  return createPortal(
    <div
      className="fixed inset-0 z-50 flex items-center justify-center"
      role="dialog"
      aria-modal="true"
      aria-labelledby={title ? "modal-title" : undefined}
      aria-describedby={description ? "modal-description" : undefined}
    >
      {/* Backdrop */}
      <div
        className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in"
        onClick={closeOnBackdrop ? onClose : undefined}
      />

      {/* Modal */}
      <div
        ref={modalRef}
        className={`relative z-10 w-full ${sizeClasses[size]} animate-in zoom-in-95 slide-in-from-bottom-4 duration-200`}
      >
        <div className="rounded-lg bg-white shadow-xl">
          {/* Header */}
          {(title || description) && (
            <div className="border-b px-6 py-4">
              {title && (
                <h2 id="modal-title" className="text-xl font-semibold">
                  {title}
                </h2>
              )}
              {description && (
                <p
                  id="modal-description"
                  className="mt-1 text-sm text-gray-600"
                >
                  {description}
                </p>
              )}
            </div>
          )}

          {/* Close button */}
          <button
            onClick={onClose}
            className="absolute right-4 top-4 rounded-lg p-1 hover:bg-gray-100"
            aria-label="Close modal"
          >
            <X className="h-5 w-5" />
          </button>

          {/* Content */}
          <div className="p-6">{children}</div>
        </div>
      </div>
    </div>,
    document.body
  );
}

ドロワーコンポーネント

interface DrawerProps {
  isOpen: boolean;
  onClose: () => void;
  position?: "left" | "right" | "bottom";
  title?: string;
  children: React.ReactNode;
}

export function Drawer({
  isOpen,
  onClose,
  position = "right",
  title,
  children,
}: DrawerProps) {
  // Similar hooks as Modal (ESC, focus trap, scroll lock)

  const positionClasses = {
    left: "left-0 top-0 h-full w-80 animate-in slide-in-from-left",
    right: "right-0 top-0 h-full w-80 animate-in slide-in-from-right",
    bottom: "bottom-0 left-0 right-0 h-96 animate-in slide-in-from-bottom",
  };

  if (!isOpen) return null;

  return createPortal(
    <div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
      <div
        className="absolute inset-0 bg-black/50 backdrop-blur-sm"
        onClick={onClose}
      />
      <div
        className={`absolute ${positionClasses[position]} bg-white shadow-xl`}
      >
        <div className="flex h-full flex-col">
          <div className="flex items-center justify-between border-b px-6 py-4">
            <h2 className="text-xl font-semibold">{title}</h2>
            <button onClick={onClose} aria-label="Close drawer">
              <X className="h-5 w-5" />
            </button>
          </div>
          <div className="flex-1 overflow-y-auto p-6">{children}</div>
        </div>
      </div>
    </div>,
    document.body
  );
}

よくあるユースケース

確認ダイアログ
interface ConfirmDialogProps {
  isOpen: boolean;
  onClose: () => void;
  onConfirm: () => void;
  title: string;
  description: string;
  confirmText?: string;
  cancelText?: string;
  variant?: "danger" | "default";
}

export function ConfirmDialog({
  isOpen,
  onClose,
  onConfirm,
  title,
  description,
  confirmText = "Confirm",
  cancelText = "Cancel",
  variant = "default",
}: ConfirmDialogProps) {
  return (
    <Modal isOpen={isOpen} onClose={onClose} size="sm">
      <div className="space-y-4">
        <div>
          <h3 className="text-lg font-semibold">{title}</h3>
          <p className="mt-2 text-sm text-gray-600">{description}</p>
        </div>
        <div className="flex justify-end gap-3">
          <Button variant="outline" onClick={onClose}>
            {cancelText}
          </Button>
          <Button
            variant={variant === "danger" ? "destructive" : "default"}
            onClick={() => {
              onConfirm();
              onClose();
            }}
          >
            {confirmText}
          </Button>
        </div>
      </div>
    </Modal>
  );
}
編集フォームモーダル
export function EditUserModal({ user, isOpen, onClose }: EditUserModalProps) {
  const [isSaving, setIsSaving] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSaving(true);
    // Save logic
    onClose();
  };

  return (
    <Modal isOpen={isOpen} onClose={onClose} title="Edit User" size="md">
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <Label htmlFor="name">Name</Label>
          <Input id="name" defaultValue={user.name} />
        </div>
        <div>
          <Label htmlFor="email">Email</Label>
          <Input id="email" type="email" defaultValue={user.email} />
        </div>
        <div className="flex justify-end gap-3">
          <Button type="button" variant="outline" onClick={onClose}>
            Cancel
          </Button>
          <Button type="submit" isLoading={isSaving}>
            Save Changes
          </Button>
        </div>
      </form>
    </Modal>
  );
}
詳細表示ドロワー
export function UserDetailDrawer({
  user,
  isOpen,
  onClose,
}: UserDetailDrawerProps) {
  return (
    <Drawer isOpen={isOpen} onClose={onClose} title={user.name}>
      <div className="space-y-6">
        <div className="flex items-center gap-4">
          <Avatar src={user.avatar} size="lg" />
          <div>
            <h3 className="font-semibold">{user.name}</h3>
            <p className="text-sm text-gray-600">{user.email}</p>
          </div>
        </div>
        <div className="space-y-4">
          <div>
            <h4 className="text-sm font-medium text-gray-700">Role</h4>
            <p className="mt-1">{user.role}</p>
          </div>
          <div>
            <h4 className="text-sm font-medium text-gray-700">Department</h4>
            <p className="mt-1">{user.department}</p>
          </div>
          <div>
            <h4 className="text-sm font-medium text-gray-700">Joined</h4>
            <p className="mt-1">{formatDate(user.joinedAt)}</p>
          </div>
        </div>
      </div>
    </Drawer>
  );
}

ベストプラクティス

  1. ポータルレンダリング:親 DOM の外側にレンダリング
  2. フォーカス管理:フォーカスをトラップし、閉じるときに復元
  3. キーボード対応:ESC キーで閉じる、Tab キーで移動
  4. ARIA 属性:role、aria-modal、aria-labelledby
  5. スクロールロック:開いている間、body のスクロールを防止
  6. バックドロップ:外側のクリックで閉じる(任意)
  7. アニメーション:滑らかな表示・非表示のトランジション
  8. モバイル対応:小さなデバイスでは全画面表示

出力チェックリスト

  • ポータルを使用するモーダルコンポーネント
  • ドロワーコンポーネント(左/右/下)
  • フォーカストラップの実装
  • ESC キーハンドラー
  • body のスクロールロック
  • クリックで閉じるバックドロップ
  • ARIA 属性
  • 滑らかなアニメーション
  • 閉じるボタン
  • ユースケースのサンプル(確認、編集、詳細)

コメント