# 玻璃浮层（Non-modal Popover）

- 类型 / 材质：`popover` / `glass-apple`（玻璃）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Radix Popover 的焦点行为 + Apple HIG 的浮层材质
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/popover/glass-apple` ｜ 条目：`/r/popover--glass-apple.json`
- 必缺状态：焦点 / 按压
- 整页场景：
  - 发布记录（`/r/scene--record.json`）

## 规格

- 触发器 aria-haspopup=dialog + aria-expanded + aria-controls；浮层 role=dialog 以标题命名
- 打开后焦点进入浮层（tabIndex=-1）；Esc / 点外关闭，关闭时焦点归还触发器
- 非模态：不锁滚动、不拦 Tab；点外才关
- fixed + portal 定位，水平夹回视口，下方放不下翻到上方

## 参数（CSS 变量）

`--color-accent` `--color-surface` `--color-text` `--color-text-muted` `--dur-fast` `--ease-standard` `--glass-bg` `--glass-border` `--shadow-lg`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `trigger` | `ReactNode` | 必填 | 触发器文字 |
| `title` | `string` | 可选 | 浮层标题；也是 dialog 的可访问名 |
| `children` | `ReactNode` | 必填 | — |
| `placement` | `'top' | 'bottom'` | 可选 | — |
| `className` | `string` | 可选 | — |

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

```
复现「玻璃浮层」— Non-modal Popover（浮层 / Glass）
灵感来源：Radix Popover 的焦点行为 + Apple HIG 的浮层材质
规格：
- 触发器 aria-haspopup=dialog + aria-expanded + aria-controls；浮层 role=dialog 以标题命名
- 打开后焦点进入浮层（tabIndex=-1）；Esc / 点外关闭，关闭时焦点归还触发器
- 非模态：不锁滚动、不拦 Tab；点外才关
- fixed + portal 定位，水平夹回视口，下方放不下翻到上方
参数（CSS 变量）：--color-accent --color-surface --color-text --color-text-muted --dur-fast --ease-standard --glass-bg --glass-border --shadow-lg
必缺状态：焦点 / 按压
整页场景：发布记录
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/popover/glass-apple · 标准 interaction /design#interaction · 调研笔记 /showcase/component/popover/glass-apple#notes
```

## 调研笔记

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

interface Props {
  /** 触发器文字 */
  trigger: ReactNode;
  /** 浮层标题；也是 dialog 的可访问名 */
  title?: string;
  children: ReactNode;
  placement?: 'top' | 'bottom';
  className?: string;
}

export function GlassApplePopover({
  trigger,
  title,
  children,
  placement = 'bottom',
  className = '',
}: Props) {
  const id = useId();
  const [open, setOpen] = useState(false);
  const { mounted, entering, leaving } = useOverlay(open);
  const { triggerRef, layerRef, pos } = useLayerPosition(mounted, { placement });
  useDismiss({
    open,
    onClose: () => setOpen(false),
    trigger: triggerRef,
    layer: layerRef,
  });

  // 打开即把焦点交给浮层：Esc 与 Tab 从浮层里开始走，关掉时再还给触发器
  useEffect(() => {
    // 定位算完、浮层可见之后再给焦点：hidden 元素上的 focus() 会被静默吞掉
    if (mounted && pos) layerRef.current?.focus();
  }, [mounted, pos]);

  const attachTrigger = (node: HTMLButtonElement | null) => {
    triggerRef.current = node;
  };

  return (
    <>
      <button
        ref={attachTrigger}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls={open ? id : undefined}
        onClick={() => setOpen((v) => !v)}
        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
        "
      >
        {trigger}
      </button>

      {mounted && (
        <Portal>
          <div
            ref={layerRef}
            id={id}
            role="dialog"
            aria-label={title ?? '浮层'}
            tabIndex={-1}
            style={layerStyle(pos)}
            className={`
              z-50 w-64 p-4 rounded-xl outline-none
              bg-[var(--glass-bg)] border border-[var(--glass-border)] backdrop-blur-xl
              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}
            `}
          >
            {title && <h3 className="text-sm font-medium mb-2">{title}</h3>}
            <div className="text-xs text-[var(--color-text-muted)] leading-relaxed">{children}</div>
          </div>
        </Portal>
      )}
    </>
  );
}
