popover · minimal · 灵感来源:Refactoring UI(实底面板 + 发丝边)
点开后人把焦点交给浮层;Esc / 点外关闭,焦点还给触发器
贴边打开时浮层会被夹回视口,不横向溢出
Non-modal Popover · 极简浮层 · 浮层 / Minimal
做一个 Non-modal Popover(极简浮层):
- 同一份 props(trigger / title / children / placement)
- 实底 + 发丝边 + shadow-lg,靠投影分层
- 焦点进出、Esc / 点外关闭与定位夹取同玻璃浮层
用这些 CSS 变量:--color-accent --color-divider --color-surface --color-text --color-text-muted --dur-fast --ease-standard --glass-border --shadow-lg
灵感参考:Refactoring UI(实底面板 + 发丝边)--color-accent --color-divider --color-surface --color-text --color-text-muted --dur-fast --ease-standard --glass-border --shadow-lg
焦点 / 按压
(尚未接进整页场景)
/r/popover--minimal.json
免外壳的 markdown 版:/llms/components/popover--minimal.md--shadow-lg:浮层与背景靠投影分层,不靠模糊import { useEffect, useId, useState, type ReactNode } from 'react';
import { useOverlay } from '~/lib/overlay';
import { Portal, layerStyle, useDismiss, useLayerPosition } from '~/lib/layer';
interface Props {
/** 触发器文字 */
trigger: ReactNode;
/** 浮层标题;也是 dialog 的可访问名 */
title?: string;
children: ReactNode;
placement?: 'top' | 'bottom';
className?: string;
}
export function MinimalPopover({
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 从浮层里开始走,关掉时再还给触发器。
// 等定位算完(浮层可见)再给焦点,hidden 元素上的 focus() 会被静默吞掉。
useEffect(() => {
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(--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}
`}
>
{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>
)}
</>
);
}
src/components/popover/minimal/