# 极简抽屉（Side Sheet / Drawer）

- 类型 / 材质：`drawer` / `minimal`（极简）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Refactoring UI（实底面板 + 发丝线）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/drawer/minimal` ｜ 条目：`/r/drawer--minimal.json`
- 必缺状态：焦点 / 禁用
- 整页场景：尚未接入

## 规格

- 同一份 props（open / onClose / title / side / children / footer）
- 实底 + 发丝边 + shadow-lg，没有模糊
- 焦点纪律与关闭路径同玻璃抽屉

## 参数（CSS 变量）

`--color-accent` `--color-divider` `--color-surface` `--color-text` `--color-text-muted` `--color-text-subtle` `--dur-fast` `--ease-standard` `--glass-border` `--shadow-lg` `--shadow-scrim` `--text-2xs`

## Props 契约

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

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

```
复现「极简抽屉」— Side Sheet / Drawer（抽屉 / Minimal）
灵感来源：Refactoring UI（实底面板 + 发丝线）
规格：
- 同一份 props（open / onClose / title / side / children / footer）
- 实底 + 发丝边 + shadow-lg，没有模糊
- 焦点纪律与关闭路径同玻璃抽屉
参数（CSS 变量）：--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --dur-fast --ease-standard --glass-border --shadow-lg --shadow-scrim --text-2xs
必缺状态：焦点 / 禁用
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/drawer/minimal · 标准 interaction /design#interaction · 调研笔记 /showcase/component/drawer/minimal#notes
```

## 调研笔记

import { useEffect, useId, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { Icon } from '@/components/ui-echo/Icon';
import { useOverlay } from '@/lib/ui-echo/overlay';
import { Portal } from '@/lib/ui-echo/layer';

interface Props {
  open: boolean;
  onClose: () => void;
  title?: string;
  side?: 'right' | 'left';
  children?: ReactNode;
  footer?: ReactNode;
}

/**
 * 抽屉：复用 modal 的焦点纪律（打开移入、Tab 首尾循环、关闭归还），
 * 视觉上从侧边滑入，遮罩点击与 Esc 都能关。
 */
export function MinimalDrawer({
  open,
  onClose,
  title = '详情',
  side = 'right',
  children,
  footer,
}: Props) {
  const titleId = useId();
  const panelRef = useRef<HTMLDivElement>(null);
  const restoreRef = useRef<HTMLElement | null>(null);
  const { mounted, entering, leaving } = useOverlay(open);

  useEffect(() => {
    if (!open) return;
    restoreRef.current = document.activeElement as HTMLElement | null;
  }, [open]);

  useEffect(() => {
    // 面板要等 useOverlay 挂上去才存在，按 mounted 触发才能把焦点送进去
    if (!mounted) return;

    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 === 'Escape') {
        e.stopPropagation();
        onClose();
        return;
      }
      if (e.key !== 'Tab') return;
      const list = focusables();
      if (!list.length) 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();
      }
    };

    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [mounted, onClose]);

  useEffect(() => {
    if (!open) restoreRef.current?.focus();
  }, [open]);

  if (!mounted) return null;

  const sideClasses = side === 'right' ? 'inset-y-0 right-0 rounded-l-2xl' : 'inset-y-0 left-0 rounded-r-2xl';
  const hiddenTransform = side === 'right' ? 'translate-x-full' : '-translate-x-full';

  return (
    <Portal>
      <div
        onClick={onClose}
        aria-hidden="true"
        className={`
          fixed inset-0 z-40 bg-[var(--shadow-scrim)]
          transition-opacity duration-[var(--dur-fast)] ease-[var(--ease-standard)]
          ${entering || leaving ? 'opacity-0' : 'opacity-100'}
        `}
      />
      <div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        tabIndex={-1}
        className={`
          fixed ${sideClasses} z-50
          w-[min(420px,100vw)] flex flex-col
          bg-[var(--color-surface)] border border-[var(--color-divider)]
          shadow-[var(--shadow-lg)]
          transition-transform duration-[var(--dur-fast)] ease-[var(--ease-standard)]
          ${entering || leaving ? hiddenTransform : 'translate-x-0'}
        `}
      >
        <header className="flex items-center justify-between gap-3 px-5 py-4 border-b border-[var(--glass-border)]">
          <h2 id={titleId} className="text-sm font-medium">
            {title}
          </h2>
          <button
            type="button"
            onClick={onClose}
            aria-label="关闭"
            className="press-feedback grid place-items-center size-7 rounded-full text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer"
          >
            <Icon icon={X} size={14} />
          </button>
        </header>

        <div className="flex-1 overflow-y-auto px-5 py-4 text-sm">{children}</div>

        {footer && (
          <footer className="px-5 py-3 border-t border-[var(--glass-border)] flex items-center justify-end gap-2">
            {footer}
          </footer>
        )}
      </div>
    </Portal>
  );
}
