# 极简菜单（Menu Button (APG)）

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

## 规格

- 同一份 props（label / items / placement）
- 实底 + 发丝边 + shadow-lg，没有模糊
- APG 键位与焦点规则同玻璃菜单

## 参数（CSS 变量）

`--color-divider` `--color-surface` `--color-text` `--color-text-muted` `--color-text-subtle` `--color-warning-fg` `--dur-fast` `--ease-standard` `--glass-border` `--glass-highlight` `--shadow-lg`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `label` | `ReactNode` | 必填 | 触发器文字 |
| `items` | `MenuItem[]` | 必填 | — |
| `placement` | `'top' | 'bottom'` | 可选 | — |
| `className` | `string` | 可选 | — |

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

```
复现「极简菜单」— Menu Button (APG)（菜单 / Minimal）
灵感来源：Refactoring UI（实底 + 发丝边）
规格：
- 同一份 props（label / items / placement）
- 实底 + 发丝边 + shadow-lg，没有模糊
- APG 键位与焦点规则同玻璃菜单
参数（CSS 变量）：--color-divider --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --ease-standard --glass-border --glass-highlight --shadow-lg
必缺状态：焦点 / 按压 / 禁用
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/menu/minimal · 标准 interaction /design#interaction · 调研笔记 /showcase/component/menu/minimal#notes
```

## 调研笔记

import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { useOverlay } from '@/lib/ui-echo/overlay';
import { Portal, layerStyle, useDismiss, useLayerPosition } from '@/lib/ui-echo/layer';

export interface MenuItem {
  label: string;
  onSelect?: () => void;
  disabled?: boolean;
  /** 破坏性操作：文字转语义色，不换底色 */
  danger?: boolean;
}

interface Props {
  /** 触发器文字 */
  label: ReactNode;
  items: MenuItem[];
  placement?: 'top' | 'bottom';
  className?: string;
}

export function MinimalMenu({ label, items, placement = 'bottom', className = '' }: Props) {
  const id = useId();
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const { mounted, entering, leaving } = useOverlay(open);
  const { triggerRef, layerRef, pos } = useLayerPosition(mounted, { placement });
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const initialFocus = useRef(0);

  useDismiss({
    open,
    onClose: () => setOpen(false),
    trigger: triggerRef,
    layer: layerRef,
  });

  const enabled = (i: number) => !items[i]?.disabled;
  const firstEnabled = () => items.findIndex((_, i) => enabled(i));
  const lastEnabled = () => items.map((_, i) => i).reverse().find(enabled) ?? 0;

  const focusAt = (index: number) => {
    setActive(index);
    itemRefs.current[index]?.focus();
  };

  const move = (dir: 1 | -1) => {
    const n = items.length;
    let next = active;
    for (let step = 0; step < n; step += 1) {
      next = (next + dir + n) % n;
      if (enabled(next)) return focusAt(next);
    }
  };

  const openWith = (index: number) => {
    initialFocus.current = index;
    setOpen(true);
    setActive(index);
  };

  // 打开后把焦点送进菜单：键盘用户不会掉在触发器上按空。
  // 等定位算完（pos 就绪、菜单可见）再给焦点，否则 focus() 会被 hidden 吞掉。
  useEffect(() => {
    if (!mounted || !pos) return;
    const target = enabled(initialFocus.current) ? initialFocus.current : firstEnabled();
    if (target !== -1) focusAt(target);
  }, [mounted, pos]);

  const onMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      move(1);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      move(-1);
    } else if (e.key === 'Home') {
      e.preventDefault();
      const first = firstEnabled();
      if (first !== -1) focusAt(first);
    } else if (e.key === 'End') {
      e.preventDefault();
      focusAt(lastEnabled());
    }
  };

  const select = (index: number) => {
    const item = items[index];
    if (!item || item.disabled) return;
    item.onSelect?.();
    setOpen(false);
  };

  return (
    <>
      <button
        ref={(node) => {
          triggerRef.current = node;
        }}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? id : undefined}
        onClick={() => (open ? setOpen(false) : openWith(0))}
        onKeyDown={(e) => {
          if (e.key === 'ArrowDown') {
            e.preventDefault();
            openWith(0);
          } else if (e.key === 'ArrowUp') {
            e.preventDefault();
            openWith(lastEnabled());
          }
        }}
        className="
          press-feedback px-3.5 py-2 rounded-lg text-xs
          border border-[var(--glass-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)]
          hover:text-[var(--color-text)] transition-colors cursor-pointer
        "
      >
        {label}
      </button>

      {mounted && (
        <Portal>
          <div
            ref={layerRef}
            id={id}
            role="menu"
            aria-label={typeof label === 'string' ? label : '菜单'}
            style={layerStyle(pos)}
            onKeyDown={onMenuKey}
            className={`
              z-50 min-w-[168px] p-1 rounded-lg
              bg-[var(--color-surface)] border border-[var(--color-divider)]
              shadow-[var(--shadow-lg)]
              transition-[opacity,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]
              ${entering || leaving ? 'opacity-0 translate-y-1' : 'opacity-100 translate-y-0'}
              ${className}
            `}
          >
            {items.map((item, i) => (
              <button
                key={item.label}
                ref={(node) => {
                  itemRefs.current[i] = node;
                }}
                type="button"
                role="menuitem"
                tabIndex={active === i ? 0 : -1}
                disabled={item.disabled}
                onPointerEnter={() => setActive(i)}
                onClick={() => select(i)}
                className={`
                  w-full text-left px-3 py-2 rounded-md text-xs
                  transition-colors cursor-pointer
                  ${item.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--glass-highlight)]'}
                  ${item.danger ? 'text-[var(--color-warning-fg)]' : 'text-[var(--color-text)]'}
                `}
              >
                {item.label}
              </button>
            ))}
          </div>
        </Portal>
      )}
    </>
  );
}
