# 极简模态（Flat Minimal Dialog (APG)）

- 类型 / 材质：`modal` / `minimal`（极简）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Refactoring UI（用留白与尺寸分层，不靠模糊与光晕）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/modal/minimal` ｜ 条目：`/r/modal--minimal.json`
- 必缺状态：焦点 / 禁用
- 整页场景：尚未接入

## 规格

- role=dialog + aria-modal + aria-labelledby
- 焦点陷阱：Tab 在内部循环，关闭后焦点归还触发元素
- Esc 关闭 + 点遮罩关闭；遮罩不模糊
- 面板白底 + 发丝边 + 软阴影，内容区退到页面底色

## 参数（CSS 变量）

`--color-accent` `--color-bg` `--color-divider` `--color-surface` `--color-text` `--color-text-muted` `--fw-semibold` `--shadow-lg` `--shadow-scrim` `--shadow-sm`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `open` | `boolean` | 必填 | — |
| `onClose` | `() => void` | 必填 | — |
| `title` | `string` | 可选 | — |
| `children` | `ReactNode` | 可选 | — |
| `actions` | `ReactNode` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「极简模态」— Flat Minimal Dialog (APG)（模态 / Minimal）
灵感来源：Refactoring UI（用留白与尺寸分层，不靠模糊与光晕）
规格：
- role=dialog + aria-modal + aria-labelledby
- 焦点陷阱：Tab 在内部循环，关闭后焦点归还触发元素
- Esc 关闭 + 点遮罩关闭；遮罩不模糊
- 面板白底 + 发丝边 + 软阴影，内容区退到页面底色
参数（CSS 变量）：--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --fw-semibold --shadow-lg --shadow-scrim --shadow-sm
必缺状态：焦点 / 禁用
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/modal/minimal · 标准 interaction /design#interaction · 调研笔记 /showcase/component/modal/minimal#notes
```

## 调研笔记

import { type ReactNode, useEffect, useId, useRef } from 'react';
import { useOverlay } from '@/lib/ui-echo/overlay';

interface Props {
  open: boolean;
  onClose: () => void;
  title?: string;
  children?: ReactNode;
  actions?: ReactNode;
}

/**
 * 极简模态：与玻璃模态同一份 props，
 * 遮罩不做模糊，面板是白底 + 发丝边 + 一档软阴影；层级由尺寸与留白给出。
 */
export function MinimalModal({
  open,
  onClose,
  title = '极简模态',
  children,
  actions,
}: Props) {
  const panelRef = useRef<HTMLDivElement>(null);
  const titleId = useId();
  const { mounted, entering, leaving } = useOverlay(open);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  // APG Dialog：焦点进模态 → Tab 在里面循环 → 关闭后焦点归还给触发元素
  useEffect(() => {
    if (!open) return;
    const restore = document.activeElement as HTMLElement | null;
    const focusables = () =>
      Array.from(
        panelRef.current?.querySelectorAll<HTMLElement>(
          'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
        ) ?? [],
      );
    focusables()[0]?.focus();

    const onKey = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
      const list = focusables();
      if (list.length === 0) return;
      const first = list[0];
      const last = list[list.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => {
      window.removeEventListener('keydown', onKey);
      restore?.focus();
    };
  }, [open]);

  if (!mounted) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      className="overlay-scrim fixed inset-0 z-50 flex items-center justify-center"
      data-leaving={leaving || undefined}
      style={{ background: 'var(--shadow-scrim)' }}
      onClick={onClose}
    >
      <div
        ref={panelRef}
        aria-labelledby={titleId}
        onClick={(e) => e.stopPropagation()}
        data-entering={entering || undefined}
        data-leaving={leaving || undefined}
        className="
          overlay-panel
          w-[480px] max-w-[92vw]
          rounded-lg
          bg-[var(--color-surface)]
          border border-[var(--color-divider)]
          shadow-[var(--shadow-lg)]
          p-6
        "
      >
        <div className="flex items-start justify-between mb-4">
          <h3
            id={titleId}
            className="text-xl"
            style={{ fontWeight: 'var(--fw-semibold)' }}
          >
            {title}
          </h3>
          <button
            type="button"
            onClick={onClose}
            aria-label="关闭"
            className="
              press-feedback
              size-8 rounded-md
              bg-[var(--color-surface)] border border-[var(--color-divider)]
              text-[var(--color-text-muted)] hover:text-[var(--color-text)]
            "
          >
            ✕
          </button>
        </div>

        <div className="
          p-4 rounded-md mb-5
          bg-[var(--color-bg)] border border-[var(--color-divider)]
          text-sm text-[var(--color-text)] min-h-[60px]
        ">
          {children ?? '模态内容区'}
        </div>

        <div className="flex items-center justify-end gap-2">
          {actions ?? (
            <button
              type="button"
              onClick={onClose}
              className="
                press-feedback
                px-4 py-2 rounded-md text-sm
                bg-[var(--color-accent)] text-[var(--color-bg)] font-medium
              "
            >
              确定
            </button>
          )}
        </div>
      </div>
    </div>
  );
}
