Merge pull request #328 from fsolla/cursor/pool-i317-1bee052c-c52f
B146 — FAB overlay idle on open; close/handle chrome
This commit is contained in:
commit
466a34525d
3 changed files with 163 additions and 14 deletions
75
docs/plans/fab-overlay-sem-autofocus-e-fechar-impl.md
Normal file
75
docs/plans/fab-overlay-sem-autofocus-e-fechar-impl.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# 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` → `preventDefault` + foco no `DialogTitle` (`titleRef`, padrão B42)
|
||||
- Dialog: `md:pr-10` em `OverlaySearchChrome` (reserva faixa para X `right-3 size-8`)
|
||||
- Drawer: `showSwipeHandle` no `Drawer`; `initialFocus={titleRef}` 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
|
||||
|
||||
**Defer (gatilho):** extrair `HomeChromeRetractionShell` compartilhado com `CampaignHomeLayout` quando um 3º chrome retrátil for tocado de novo (já é o 3º — `quick-actions-chrome`; extrair em issue futura, fora do appetite B146).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
| Risco | Mitigação |
|
||||
| ------------------------------------------ | ------------------------------------------------------ |
|
||||
| `initialFocus={false}` quebra a11y teclado | Foco no título sr-only (B42), não na busca nem em void |
|
||||
| `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`
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { CampaignGlobalSearchBody } from '@/components/campaign/dashboard/CampaignGlobalSearchMount'
|
||||
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
|
||||
|
|
@ -63,7 +63,7 @@ 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>
|
||||
)
|
||||
|
|
@ -114,6 +114,7 @@ export const CampaignQuickActionsOverlay = ({
|
|||
}) => {
|
||||
const isMobile = useIsMobile()
|
||||
const { clear } = useHomeSearch()
|
||||
const titleRef = useRef<HTMLHeadingElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) clear()
|
||||
|
|
@ -121,12 +122,15 @@ export const CampaignQuickActionsOverlay = ({
|
|||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>
|
||||
<DrawerContent
|
||||
id="CampaignQuickActionsOverlay"
|
||||
initialFocus={titleRef}
|
||||
className="max-h-[85dvh] border-t border-border bg-background text-foreground [--drawer-height:auto]"
|
||||
>
|
||||
<DrawerTitle className="sr-only">Ações rápidas</DrawerTitle>
|
||||
<DrawerTitle ref={titleRef} tabIndex={-1} className="sr-only outline-none">
|
||||
Ações rápidas
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
Ações do contexto e busca na campanha
|
||||
</DrawerDescription>
|
||||
|
|
@ -143,9 +147,15 @@ 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()
|
||||
titleRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Ações rápidas</DialogTitle>
|
||||
<DialogTitle ref={titleRef} tabIndex={-1} className="outline-none">
|
||||
Ações rápidas
|
||||
</DialogTitle>
|
||||
<DialogDescription>Ações do contexto e busca na campanha</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -54,6 +54,21 @@ const searchWithPriorityPayload: HomeSearchSuccessResponse = {
|
|||
|
||||
const matchMediaMock = vi.fn()
|
||||
|
||||
let innerWidthSpy: ReturnType<typeof vi.spyOn> | undefined
|
||||
|
||||
const stubMobileViewport = () => {
|
||||
innerWidthSpy = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(375)
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
matchMediaMock.mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
|
|
@ -69,6 +84,8 @@ beforeEach(() => {
|
|||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
innerWidthSpy?.mockRestore()
|
||||
innerWidthSpy = undefined
|
||||
vi.mocked(postCampaignJson).mockReset()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
|
@ -331,16 +348,63 @@ 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"]')
|
||||
const title = screen.getByText('Ações rápidas')
|
||||
|
||||
expect(document.activeElement).not.toBe(input)
|
||||
expect(document.activeElement).toBe(title)
|
||||
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)', () => {
|
||||
stubMobileViewport()
|
||||
|
||||
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'),
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
stubMobileViewport()
|
||||
|
||||
vi.mocked(postCampaignJson).mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue