mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
commit
285caba300
6 changed files with 200 additions and 30 deletions
67
docs/plans/acoes-rapidas-grade-3x2-fab-overlay-impl.md
Normal file
67
docs/plans/acoes-rapidas-grade-3x2-fab-overlay-impl.md
Normal 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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue