B146 — FAB overlay idle on open; close/handle chrome fixes

- Block Dialog onOpenAutoFocus and Drawer initialFocus so search does not
  steal focus on open (actions stay visible per B132 idle state).
- Reserve md:pr-10 on search chrome so desktop close button does not overlap.
- Enable showSwipeHandle on mobile drawer (no X button).
- Unit tests for idle state, swipe handle, and close spacing.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:03:04 +00:00
parent d0cd103b90
commit 23e71d2de3
No known key found for this signature in database
3 changed files with 141 additions and 2 deletions

View file

@ -0,0 +1,73 @@
# Impl: FAB — overlay abre sem foco na busca; fechar não cobre o campo
Status: aprovado
Atualizado em: 2026-08-02
Issue: #317
Intenção: docs/plans/fab-overlay-sem-autofocus-e-fechar.md
Appetite restante: herdado (~0,5 dia eng)
## Leitura da intenção
- **Outcome:** Ao abrir o FAB, o overlay inicia em estado idle — ações visíveis, busca sem foco. Desktop: X não sobrepõe a busca. Mobile: handle de swipe, sem X.
- **O que NÃO negociar:** Ritual B132 (foco deliberado na busca ainda recolhe ações); catálogo/lockdown/limpeza de query inalterados; sem migration/Consent/RBAC.
- **O que reavaliar:** A hipótese de `autoFocus` explícito no input estava errada — o foco vem do trap de foco padrão do Dialog (Radix `onOpenAutoFocus`) e do Drawer (base-ui `initialFocus` default).
## Abordagem recomendada
```mermaid
flowchart LR
FAB[FAB click] --> Open[Overlay opens]
Open --> NoFocus[Block open autofocus]
NoFocus --> Idle[Idle: actions visible]
Idle -->|user taps search| Retract[B132 retract]
Open --> Desktop[md+: pr on search + default X]
Open --> Mobile[mobile: showSwipeHandle, no X]
```
**Opções consideradas:**
| Opção | Descrição | Veredito |
| ----- | --------- | -------- |
| A | `onOpenAutoFocus` / `initialFocus={false}` no overlay + `md:pr-10` na busca + `showSwipeHandle` no drawer | **Recomendada** — mínima, escopada ao dono |
| B | Header dedicado com X em flow no dialog | Rejeitada — mais chrome, fora do appetite |
| C | `showCloseButton={false}` + close custom em todo dialog kit | Rejeitada — polui kit global |
**Recomendação:** A — três props no `CampaignQuickActionsOverlay`, sem alterar `dialog.tsx`/`Drawer.tsx` globalmente.
### Componentes / mudanças
- **`CampaignQuickActionsOverlay`** (`src/components/campaign/shell/CampaignQuickActionsOverlay.tsx`):
- Dialog: `onOpenAutoFocus={(e) => e.preventDefault()}` em `DialogContent`
- Dialog: `md:pr-10` em `OverlaySearchChrome` (reserva faixa para X `right-3 size-8`)
- Drawer: `showSwipeHandle` no `Drawer`; `initialFocus={false}` em `DrawerContent`
- **Migration:** sem migration
- **Access / Consent:** N/A
- **UI:** Impeccable B — polimento do chrome existente
### Dados → forma
N/A
## Fases verificáveis
1. **UI** — props de foco + padding + handle; testes unitários no spec existente
2. **Gates** — `pnpm gate:fast`; push via `pnpm push`
## Rabbit holes / Não escopo (engenharia)
- Alterar `DialogContent` default global
- Desligar recolhimento B132 ao focar
- Redesenhar grade ou catálogo
## Riscos e mitigação
| Risco | Mitigação |
| ----- | --------- |
| `initialFocus={false}` quebra a11y teclado | Tab ainda alcança busca/ações; trap permanece |
| `pr-10` insuficiente em telas estreitas | `md:` só no breakpoint dialog; mobile usa drawer |
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes unitários previstos em `campaignQuickActionsDrawer.unit.spec.tsx`

View file

@ -63,7 +63,10 @@ const OverlayActionsChrome = ({
}
const OverlaySearchChrome = ({ className }: { className?: string }) => (
<div data-slot="quick-actions-search" className={cn('min-w-0', className)}>
<div
data-slot="quick-actions-search"
className={cn('min-w-0 md:pr-10', className)}
>
<CampaignGlobalSearchBody />
</div>
)
@ -121,9 +124,10 @@ export const CampaignQuickActionsOverlay = ({
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>
<DrawerContent
id="CampaignQuickActionsOverlay"
initialFocus={false}
className="max-h-[85dvh] border-t border-border bg-background text-foreground [--drawer-height:auto]"
>
<DrawerTitle className="sr-only">Ações rápidas</DrawerTitle>
@ -143,6 +147,7 @@ export const CampaignQuickActionsOverlay = ({
<DialogContent
id="CampaignQuickActionsOverlay"
className="flex max-h-[min(85dvh,40rem)] w-[calc(100vw-2rem)] max-w-lg flex-col gap-4 overflow-hidden p-4 sm:p-6"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="sr-only">
<DialogTitle>Ações rápidas</DialogTitle>

View file

@ -331,6 +331,67 @@ describe('CampaignQuickActionsOverlay (B126)', () => {
expect((screen.getByLabelText('Buscar na campanha') as HTMLInputElement).value).toBe('')
})
it('opens idle without autofocus and keeps actions visible (B146)', () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: idleSuggestPayload,
})
renderOverlay({
actions: [
{
id: 'test',
label: 'Registrar',
icon: BarChart3,
description: 'Teste.',
href: '/campanha',
},
],
})
const input = screen.getByLabelText('Buscar na campanha')
const actionsChrome = document.querySelector('[data-slot="quick-actions-chrome"]')
expect(document.activeElement).not.toBe(input)
expect(actionsChrome?.getAttribute('data-retracted')).toBeNull()
expect(actionsChrome?.className).not.toContain('grid-rows-[0fr]')
})
it('shows swipe handle and no dialog close on mobile (B146)', () => {
matchMediaMock.mockImplementation((query: string) => ({
matches: query.includes('max-width'),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: idleSuggestPayload,
})
renderOverlay()
expect(document.querySelector('[data-slot="drawer-swipe-handle"]')).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Fechar' })).toBeNull()
})
it('reserves space for close button beside search on desktop (B146)', () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: idleSuggestPayload,
})
renderOverlay()
const searchChrome = document.querySelector('[data-slot="quick-actions-search"]')
expect(searchChrome?.className).toContain('md:pr-10')
expect(screen.getByRole('button', { name: 'Fechar' })).toBeTruthy()
})
it('uses mobile drawer with actions above search', () => {
matchMediaMock.mockImplementation((query: string) => ({
matches: query.includes('max-width'),