diff --git a/docs/plans/acoes-rapidas-grade-3x2-fab-overlay-impl.md b/docs/plans/acoes-rapidas-grade-3x2-fab-overlay-impl.md new file mode 100644 index 00000000..ed959dc5 --- /dev/null +++ b/docs/plans/acoes-rapidas-grade-3x2-fab-overlay-impl.md @@ -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 diff --git a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx index 99c3d2d5..6c4e26eb 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx @@ -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(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, ) diff --git a/src/components/campaign/shell/CampaignQuickActionsHost.tsx b/src/components/campaign/shell/CampaignQuickActionsHost.tsx index 630a8c9e..edf0da2d 100644 --- a/src/components/campaign/shell/CampaignQuickActionsHost.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsHost.tsx @@ -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 ( <> - - + + ) } diff --git a/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx index 3bb8ed46..891238a8 100644 --- a/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx @@ -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} >
-
- ({ - id: action.id, - label: action.label, - icon: action.icon, - description: action.description, - href: action.href, - }))} - className="w-full" - /> -
+ ({ + id: action.id, + label: action.label, + icon: action.icon, + description: action.description, + href: action.href, + }))} + className="w-full" + />
) @@ -82,7 +81,7 @@ const CampaignQuickActionsOverlayBody = ({ ) @@ -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 ( Ações rápidas Ações do contexto e busca na campanha -
+
@@ -144,7 +148,7 @@ export const CampaignQuickActionsOverlay = ({ Ações rápidas Ações do contexto e busca na campanha -
+
diff --git a/tests/unit/campaignHomeActionButton.unit.spec.tsx b/tests/unit/campaignHomeActionButton.unit.spec.tsx index 8630dba7..9adec2a1 100644 --- a/tests/unit/campaignHomeActionButton.unit.spec.tsx +++ b/tests/unit/campaignHomeActionButton.unit.spec.tsx @@ -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( { 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( + , + ) + + 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() const scroller = getScroller(container) diff --git a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx index efe9f5cd..03fa1dde 100644 --- a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx +++ b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx @@ -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( + , + ) + + const input = screen.getByLabelText('Buscar na campanha') as HTMLInputElement + fireEvent.change(input, { target: { value: 'cairu' } }) + expect(input.value).toBe('cairu') + + rerender( + + + + + + + , + ) + rerender( + + + + + + + , + ) + + 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'),