Merge pull request #286 from fsolla/cursor/pool-i280-093296a2-744e

B132 — Ações rápidas — grade 3×2 (Início + FAB) e polimento do overlay
This commit is contained in:
Francisco Solla 2026-08-02 18:23:29 -03:00 committed by GitHub
commit 285caba300
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 200 additions and 30 deletions

View file

@ -0,0 +1,67 @@
# Impl: Ações rápidas — grade 3×2 (Início + FAB) e polimento do overlay
Status: aprovado
Atualizado em: 2026-08-02
Issue: #280
Intenção: docs/plans/acoes-rapidas-grade-3x2-fab-overlay.md
Appetite restante: ~0,5d (layout + overlay polish)
## Leitura da intenção
- **Outcome:** Início mobile e overlay FAB usam grade 3×2; overlay sem strip scroll; drawer encaixa no conteúdo; busca com respiro no topo quando focada; query limpa ao fechar/navegar.
- **O que NÃO negociar:** catálogo/lockdown; Início md+ strip; sem migration; sem snap/peek.
- **O que reavaliar:** variante `strip` no overlay → nova variante `grid` (grade em todas as larguras do overlay); bleed `-mx-4` obsoleto com grade.
## Abordagem recomendada
```mermaid
flowchart LR
Strip["CampaignHomeActionStrip"]
Strip -->|responsive| H["grid 3-col lt-md / strip md+"]
Strip -->|grid| O["grid 3-col sempre"]
Strip -->|strip| S["flex scroll"]
Host["CampaignQuickActionsHost"] -->|close/pathname| Clear["useHomeSearch.clear"]
```
**Opções consideradas:** A) reutilizar `responsive` no overlay (quebra desktop — vira strip) | B) variante `grid` dedicada | C) prop `columns={3}`
**Recomendação:** **B** — `grid` para overlay; `responsive` passa de 2→3 colunas no mobile.
**Rejeitadas:** A (aceite exige grade no dialog desktop); C (over-parameterize).
### Componentes / mudanças
- **`CampaignHomeActionStrip`:** `variant` ganha `'grid'`; `responsive` usa `grid-cols-3`; `grid` = `grid grid-cols-3 gap-3` sem overflow/pan.
- **`CampaignHomeActionButton`:** sem mudança (já `w-full` em responsive/grid).
- **`CampaignQuickActionsOverlay`:** `variant="grid"`; remove bleed wrapper; drawer sem `flex-1` stretch — altura auto com `max-h` só como teto; `pt-4` na busca quando `uiFocused`.
- **`CampaignQuickActionsHost`:** `clear()` em `onOpenChange(false)` e no `useEffect` de `pathname`.
- **Tests:** strip spec 2→3 cols; overlay strip→grid; bleed test removido/atualizado; clear on close/pathname.
- **Migration:** sem migration.
### Dados → forma
N/A — chrome de launcher.
## Fases verificáveis
1. **Strip variants** — `grid-cols-3` + variante `grid`.
2. **Overlay polish** — grid, altura conteúdo, respiro busca, clear query.
3. **Gates** — `pnpm gate:fast`; `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Refatorar `Drawer.tsx` globalmente.
- Extrair tile genérico.
- Mudar tamanho dos círculos de ação.
## Riscos e mitigação
- **3 colunas em 390px:** gap-3 + w-full já usados em B122; labels line-clamp-2.
- **Clear no Início:** provider separado no home — clear no host só afeta overlay provider.
- **Dialog desktop altura:** manter `max-h` como teto para resultados longos.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes unit previstos (strip, overlay, clear)
Self-score decision-quality: 5/5

View file

@ -13,7 +13,7 @@ import { cn } from '@/lib/utils'
/** Horizontal pan threshold (px) — below this, child link/button clicks still fire. */
export const HOME_ACTION_STRIP_DRAG_THRESHOLD_PX = 8
export type CampaignHomeActionStripVariant = 'strip' | 'responsive'
export type CampaignHomeActionStripVariant = 'strip' | 'responsive' | 'grid'
type DragSession = {
pointerId: number
@ -40,7 +40,7 @@ export const CampaignHomeActionStrip = ({
actions?: readonly (CampaignHomeActionButtonProps & { id?: string })[]
ariaLabel?: string
className?: string
/** `responsive`: 2×3 grid on mobile, horizontal strip on md+. `strip`: always horizontal. */
/** `responsive`: 3×2 grid on mobile, horizontal strip on md+. `grid`: always 3×2. `strip`: always horizontal. */
variant?: CampaignHomeActionStripVariant
}) => {
const isCoarsePointer = useCoarsePointer()
@ -48,6 +48,7 @@ export const CampaignHomeActionStrip = ({
const dragRef = useRef<DragSession | null>(null)
const suppressClickRef = useRef(false)
const isStripLayout = variant === 'strip'
const isGridLayout = variant === 'grid'
const endDragSession = useCallback((pointerId: number) => {
const session = dragRef.current
@ -136,14 +137,18 @@ export const CampaignHomeActionStrip = ({
'm-0 list-none p-0',
isStripLayout
? 'flex min-w-max snap-x snap-proximity gap-0 px-4 pb-1 md:px-0'
: 'grid grid-cols-2 gap-3 md:flex md:min-w-max md:snap-x md:snap-proximity md:gap-0 md:px-0 md:pb-1',
: isGridLayout
? 'grid grid-cols-3 gap-3'
: 'grid grid-cols-3 gap-3 md:flex md:min-w-max md:snap-x md:snap-proximity md:gap-0 md:px-0 md:pb-1',
)
const scrollerClassName = cn(
'min-w-0',
isStripLayout
? 'overflow-x-auto overflow-y-hidden overscroll-x-contain [touch-action:pan-x] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden pointer-fine:cursor-grab pointer-fine:data-[dragging=true]:cursor-grabbing pointer-fine:data-[dragging=true]:select-none'
: 'md:overflow-x-auto md:overflow-y-hidden md:overscroll-x-contain md:[touch-action:pan-x] md:[scrollbar-width:none] md:[&::-webkit-scrollbar]:hidden md:pointer-fine:cursor-grab md:pointer-fine:data-[dragging=true]:cursor-grabbing md:pointer-fine:data-[dragging=true]:select-none',
: isGridLayout
? undefined
: 'md:overflow-x-auto md:overflow-y-hidden md:overscroll-x-contain md:[touch-action:pan-x] md:[scrollbar-width:none] md:[&::-webkit-scrollbar]:hidden md:pointer-fine:cursor-grab md:pointer-fine:data-[dragging=true]:cursor-grabbing md:pointer-fine:data-[dragging=true]:select-none',
className,
)

View file

@ -1,7 +1,7 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import { CampaignQuickActionsFab } from '@/components/campaign/shell/CampaignQuickActionsFab'
@ -30,14 +30,18 @@ export const CampaignQuickActionsHost = ({ role }: { role: CampaignRole }) => {
setOpen(false)
}, [pathname])
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next)
}, [])
if (!mounted) return null
const actions = resolveQuickActionsForPath(pathname, role, context)
return (
<>
<CampaignQuickActionsFab open={open} onOpenChange={setOpen} />
<CampaignQuickActionsOverlay open={open} onOpenChange={setOpen} actions={actions} />
<CampaignQuickActionsFab open={open} onOpenChange={handleOpenChange} />
<CampaignQuickActionsOverlay open={open} onOpenChange={handleOpenChange} actions={actions} />
</>
)
}

View file

@ -1,6 +1,7 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
import { CampaignGlobalSearchBody } from '@/components/campaign/dashboard/CampaignGlobalSearchMount'
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
@ -45,19 +46,17 @@ const OverlayActionsChrome = ({
aria-hidden={retracted || undefined}
>
<div className="min-h-0 overflow-hidden">
<div className="-mx-4 w-[calc(100%+2rem)] md:mx-0 md:w-auto">
<CampaignHomeActionStrip
variant="strip"
actions={actions.map((action) => ({
id: action.id,
label: action.label,
icon: action.icon,
description: action.description,
href: action.href,
}))}
className="w-full"
/>
</div>
<CampaignHomeActionStrip
variant="grid"
actions={actions.map((action) => ({
id: action.id,
label: action.label,
icon: action.icon,
description: action.description,
href: action.href,
}))}
className="w-full"
/>
</div>
</div>
)
@ -82,7 +81,7 @@ const CampaignQuickActionsOverlayBody = ({
<OverlayActionsChrome actions={actions} retracted={focused} />
</div>
<OverlaySearchChrome
className={cn('order-2 min-w-0 md:order-1', focused ? 'mt-0' : 'mt-4 md:mt-0 md:mb-4')}
className={cn('order-2 min-w-0 md:order-1', focused ? 'mt-0 pt-4' : 'mt-4 md:mt-0 md:mb-4')}
/>
</div>
)
@ -114,19 +113,24 @@ export const CampaignQuickActionsOverlay = ({
actions: readonly CampaignQuickAction[]
}) => {
const isMobile = useIsMobile()
const { clear } = useHomeSearch()
useEffect(() => {
if (!open) clear()
}, [open, clear])
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
id="CampaignQuickActionsOverlay"
className="max-h-[85dvh] border-t border-border bg-background text-foreground"
className="max-h-[85dvh] border-t border-border bg-background text-foreground [--drawer-height:auto]"
>
<DrawerTitle className="sr-only">Ações rápidas</DrawerTitle>
<DrawerDescription className="sr-only">
Ações do contexto e busca na campanha
</DrawerDescription>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
<div className="flex flex-col overflow-y-auto px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3">
<CampaignQuickActionsOverlayContent actions={actions} />
</div>
</DrawerContent>
@ -144,7 +148,7 @@ export const CampaignQuickActionsOverlay = ({
<DialogTitle>Ações rápidas</DialogTitle>
<DialogDescription>Ações do contexto e busca na campanha</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div className="flex flex-col overflow-y-auto">
<CampaignQuickActionsOverlayContent actions={actions} />
</div>
</DialogContent>

View file

@ -193,7 +193,7 @@ describe('CampaignHomeActionStrip', () => {
expect(list?.className).toMatch(/flex/)
})
it('renders a 2-column grid on mobile when variant is responsive', () => {
it('renders a 3-column grid on mobile when variant is responsive', () => {
const { container } = render(
<CampaignHomeActionStrip
variant="responsive"
@ -208,13 +208,31 @@ describe('CampaignHomeActionStrip', () => {
expect(region).toBeTruthy()
const list = region?.querySelector('ul[role="list"]')
expect(list).toBeTruthy()
expect(list?.className).toMatch(/grid-cols-2/)
expect(list?.className).toMatch(/grid-cols-3/)
expect(list?.className).toMatch(/md:flex/)
const regionClasses = region?.className.split(/\s+/) ?? []
expect(regionClasses).not.toContain('overflow-x-auto')
expect(regionClasses.some((token) => token.includes('md:overflow-x-auto'))).toBe(true)
})
it('renders a 3-column grid when variant is grid', () => {
const { container } = render(
<CampaignHomeActionStrip
variant="grid"
actions={[
{ label: 'Um', icon: BarChart3 },
{ label: 'Dois', icon: BarChart3 },
]}
/>,
)
const region = container.querySelector('[aria-label="Ações rápidas"]')
const list = region?.querySelector('ul[role="list"]')
expect(list?.className).toMatch(/grid-cols-3/)
expect(list?.className).not.toMatch(/md:flex/)
expect(region?.className).not.toMatch(/overflow-x-auto/)
})
it('scrolls horizontally on pointer-fine drag past the threshold', () => {
const { container } = render(<CampaignHomeActionStrip variant="strip" actions={manyActions} />)
const scroller = getScroller(container)

View file

@ -133,7 +133,7 @@ describe('CampaignQuickActionsOverlay (B126)', () => {
expect(screen.getByRole('region', { name: 'Resultados da busca' })).toBeTruthy()
})
it('bleeds the action strip edge-to-edge inside the overlay gutter', () => {
it('renders actions in a 3-column grid inside the overlay chrome', () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: idleSuggestPayload,
@ -152,9 +152,10 @@ describe('CampaignQuickActionsOverlay (B126)', () => {
})
const chrome = document.querySelector('[data-slot="quick-actions-chrome"]')
const stripBleed = chrome?.firstElementChild?.firstElementChild
expect(stripBleed?.className).toContain('-mx-4')
expect(stripBleed?.querySelector('[aria-label="Ações rápidas"]')).not.toBeNull()
const list = chrome?.querySelector('ul[role="list"]')
expect(list?.className).toContain('grid-cols-3')
expect(list?.className).not.toMatch(/flex/)
expect(chrome?.querySelector('[aria-label="Ações rápidas"]')).not.toBeNull()
})
it('posts home-search when the overlay query is active', async () => {
@ -259,6 +260,77 @@ describe('CampaignQuickActionsOverlay (B126)', () => {
})
})
it('adds top padding when search is focused in the overlay', async () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: idleSuggestPayload,
})
renderOverlay({
actions: [
{
id: 'test',
label: 'Registrar',
icon: BarChart3,
description: 'Teste.',
href: '/campanha',
},
],
})
const searchChrome = document.querySelector('[data-slot="quick-actions-search"]')
const input = screen.getByLabelText('Buscar na campanha')
fireEvent.blur(input)
await waitFor(() => {
expect(searchChrome?.className).toContain('mt-4')
})
fireEvent.focus(input)
await waitFor(() => {
expect(searchChrome?.className).toContain('pt-4')
expect(searchChrome?.className).not.toContain('mt-4')
})
})
it('clears overlay search when closed', async () => {
vi.mocked(postCampaignJson).mockResolvedValue({
ok: true,
payload: idleSuggestPayload,
})
const onOpenChange = vi.fn()
const { rerender } = renderQuickActionsChrome(
<CampaignQuickActionsOverlay open onOpenChange={onOpenChange} actions={[]} />,
)
const input = screen.getByLabelText('Buscar na campanha') as HTMLInputElement
fireEvent.change(input, { target: { value: 'cairu' } })
expect(input.value).toBe('cairu')
rerender(
<TooltipProvider delayDuration={300}>
<CampaignQuickActionContextProvider>
<CampaignGlobalSearchProvider>
<CampaignQuickActionsOverlay open={false} onOpenChange={onOpenChange} actions={[]} />
</CampaignGlobalSearchProvider>
</CampaignQuickActionContextProvider>
</TooltipProvider>,
)
rerender(
<TooltipProvider delayDuration={300}>
<CampaignQuickActionContextProvider>
<CampaignGlobalSearchProvider>
<CampaignQuickActionsOverlay open onOpenChange={onOpenChange} actions={[]} />
</CampaignGlobalSearchProvider>
</CampaignQuickActionContextProvider>
</TooltipProvider>,
)
expect((screen.getByLabelText('Buscar na campanha') as HTMLInputElement).value).toBe('')
})
it('uses mobile drawer with actions above search', () => {
matchMediaMock.mockImplementation((query: string) => ({
matches: query.includes('max-width'),