Popover
ProntoConteúdo flutuante interativo aberto por click. Suporta forms, listas, ações múltiplas. Diferente de Tooltip: persiste até dismissed.
Usar quando
Menu de ações (kebab), filtros avançados em popup, mini-form contextual, configurações rápidas.
Não usar quando
Hint estático (use Tooltip). Form longo (use Drawer/Modal). Confirmação destrutiva (use Modal).
Variantes (clique pra abrir)
Microinterações
| Microinteração | Disparada por | Comportamento | Timing |
|---|---|---|---|
| Open | click no trigger | opacity 0→1 + scale 0.95→1 + translateY 4→0 | 200ms cubic-bezier(0.32,0.72,0,1) |
| Close | click outside / Esc / item selected | opacity 1→0 | 150ms ease |
| Auto-position | open near edge | Detecta colisão com viewport e flipa lado | instant antes do show |
| Item hover | mouseenter no item | Background → surfaceHover | 100ms ease |
| Keyboard nav | ↑↓ | Move foco entre items do menu | instant |
Acessibilidade
Acessibilidade — checklist
Teclado
| Enter / Space | Abre popover |
| Esc | Fecha |
| ↑ / ↓ | Navega items (se menu) |
| Tab | Foco no próximo item, Tab fora fecha |
ARIA esperado
- Trigger: aria-haspopup="menu" aria-expanded="true|false"
- Popup: role="menu" ou role="dialog"
- Items: role="menuitem"
- aria-controls="popover-id"
Notas
- Click outside fecha (registrar listener no document).
- Esc fecha + foco volta ao trigger.
- Em mobile, popover pode virar bottom sheet.
- Diferente de tooltip: popover é interativo (foco vai pra dentro).
Código
'use client';
import { useState, useEffect, useRef } from 'react';
import { T, RADIUS } from '@/lib/tokens';
export function Popover({ trigger, children, position = 'bottom-start' }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
if (open) document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<span onClick={() => setOpen(!open)} aria-haspopup="menu" aria-expanded={open}>
{trigger}
</span>
{open && (
<div role="menu" style={{
position: 'absolute', top: '100%', left: 0, marginTop: 4,
background: T.surface, border: `1px solid ${T.border}`,
borderRadius: RADIUS.md, boxShadow: T.shadowMd,
padding: 4, minWidth: 180, zIndex: 50,
}}>
{children}
</div>
)}
</div>
);
}Regras
Faça
- ✓Click outside fecha (com listener no document).
- ✓Esc fecha + foco volta ao trigger.
- ✓Auto-position: detecta colisão com viewport e flipa.
- ✓Mín 180px de width pra menu.
- ✓Item destrutivo separado por divider + cor carmim.
- ✓Em mobile: vire bottom sheet.
Não faça
- ✗Não use popover pra hint (use Tooltip).
- ✗Não force scroll dentro de popover sem max-height.
- ✗Não tenha múltiplos popovers abertos.
- ✗Não esqueça click outside (popover preso = bug).
- ✗Não use popover pra form complexo (use Drawer/Modal).
Tokens usados
| Token | Valor | Papel |
|---|---|---|
T.surface | #FFFFFF | background |
T.border | #E3DFD2 | borda |
T.surfaceHover | #F7F5EC | item hover |
T.shadowMd | 0 4px 14px ... | sombra |
T.carmim | #A32E2E | item destrutivo |
RADIUS.md | 12 | borderRadius |