mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge pull request #339 from fsolla/cursor/pool-i302-2f4dd995-03ba
B136 — Início / FAB — grade 3 colunas × 2 linhas (corrigir orientação)
This commit is contained in:
commit
90bbfeb2da
9 changed files with 272 additions and 10 deletions
62
docs/plans/inicio-acoes-grade-3cols-2rows-impl.md
Normal file
62
docs/plans/inicio-acoes-grade-3cols-2rows-impl.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Impl: Início / FAB — grade 3 colunas × 2 linhas (corrigir orientação)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-02
|
||||
Issue: #302
|
||||
Intenção: docs/plans/inicio-acoes-grade-3cols-2rows.md
|
||||
Appetite restante: ~0,5d (causa real + testes de geometria)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** Início mobile e overlay FAB mostram 6 ações staff em **3 colunas × 2 linhas** (verificável em viewport ~390), não 2×3; `md+` Início mantém strip.
|
||||
- **O que NÃO negociar:** catálogo/lockdown; sem migration; não reabrir snap/peek; liderança sem layout paralelo.
|
||||
- **O que reavaliar:** B132 já trocou `grid-cols-2` → `grid-cols-3`, mas testes só assertam `className` — a causa visual provável é **min-width dos itens da grade** (`min-width: auto` + `box-content` + `w-[5.5rem]` residual) comprimindo para 2 colunas percebidas ou clip pelo `overflow-hidden` do shell de retraction.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Strip["CampaignHomeActionStrip"]
|
||||
Strip -->|grid/responsive lt-md| G["grid-cols-3 + li min-w-0"]
|
||||
Btn["CampaignHomeActionButton"]
|
||||
Btn -->|layout=grid| W["w-full min-w-0 border-box"]
|
||||
E2E["e2e viewport 390"]
|
||||
E2E --> Geo["boundingBox: 3 por linha"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) só reafirmar `grid-cols-3` | B) `min-w-0` + `layout=grid` no botão | C) reduzir círculo/gap
|
||||
**Recomendação:** **B** — corrige sizing real sem redesign; gap-2 só se critique pedir.
|
||||
**Rejeitadas:** A (já feito em B132, insuficiente); C (fora do apetite salvo falha em 390).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`CampaignHomeActionButton`:** `layout: 'grid'` — `w-full min-w-0 max-w-full`, `box-border` (sem `box-content` na grade).
|
||||
- **`CampaignHomeActionStrip`:** `li` com `min-w-0`; `layout='grid'` quando `variant` é `grid` ou `responsive`; `gap-2` no mobile grid (mais folga horizontal em 390px); `data-layout="grid-3"` no `<ul>` para e2e.
|
||||
- **`CampaignQuickActionsOverlay`:** sem mudança estrutural (já `variant="grid"`).
|
||||
- **Testes:** helper de geometria em e2e (`campaignHomeActions.e2e.spec.ts` + overlay em `campaignMunicipalities.e2e.spec.ts`); unit para `layout=grid` e `min-w-0` no `li`.
|
||||
- **Migration:** sem migration.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Strip + button sizing** — `layout=grid`, `min-w-0`, gap ajustado.
|
||||
2. **E2e geometria** — 3 botões na primeira linha, 3 na segunda (staff); overlay FAB idem.
|
||||
3. **Gates** — `pnpm gate:fast`; `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- PWA cache bust (fora do repo); se persistir pós-deploy, runbook separado.
|
||||
- Refatorar `HomeChromeRetractionShell` globalmente.
|
||||
- Grade em `md+`.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Leader 2 ações:** grade 3-col deixa 1 célula vazia — aceite da intenção.
|
||||
- **Desktop overlay dialog:** `layout=grid` mantém `w-full` (sem `md:w-[5.5rem]`).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto
|
||||
- [ ] Invariantes AGENTS/engineering-standards
|
||||
- [ ] E2e viewport 390 com geometria 3×2 (não só className)
|
||||
|
||||
Self-score decision-quality: 5/5
|
||||
|
|
@ -25,19 +25,24 @@ export type CampaignHomeActionButtonProps = {
|
|||
href?: string
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
layout?: 'strip' | 'responsive'
|
||||
layout?: 'strip' | 'responsive' | 'grid'
|
||||
}
|
||||
|
||||
const actionControlBaseClassName =
|
||||
'group box-content flex min-h-11 flex-col items-center gap-2 rounded-md text-center outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-90 disabled:pointer-events-none disabled:opacity-50'
|
||||
'group flex min-h-11 flex-col items-center gap-2 rounded-md text-center outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-90 disabled:pointer-events-none disabled:opacity-50'
|
||||
|
||||
const actionStripControlClassName = cn(actionControlBaseClassName, 'w-[5.5rem] shrink-0 snap-start')
|
||||
const actionStripControlClassName = cn(
|
||||
actionControlBaseClassName,
|
||||
'box-content w-[5.5rem] shrink-0 snap-start',
|
||||
)
|
||||
|
||||
const actionResponsiveControlClassName = cn(
|
||||
actionControlBaseClassName,
|
||||
'w-full md:w-[5.5rem] md:shrink-0 md:snap-start',
|
||||
'w-full min-w-0 max-w-full md:box-content md:w-[5.5rem] md:max-w-none md:shrink-0 md:snap-start',
|
||||
)
|
||||
|
||||
const actionGridControlClassName = cn(actionControlBaseClassName, 'w-full min-w-0 max-w-full')
|
||||
|
||||
const circleClassName =
|
||||
'flex size-14 items-center justify-center rounded-full bg-muted text-foreground transition-[transform,colors] duration-150 ease-out motion-reduce:transition-none pointer-fine:group-hover:scale-[1.05] pointer-coarse:group-data-[pressing=true]:scale-[1.05] motion-reduce:scale-100 group-hover:bg-muted/80 group-focus-visible:bg-muted/80'
|
||||
|
||||
|
|
@ -84,7 +89,11 @@ export const CampaignHomeActionButton = ({
|
|||
})
|
||||
|
||||
const className = cn(
|
||||
layout === 'responsive' ? actionResponsiveControlClassName : actionStripControlClassName,
|
||||
layout === 'grid'
|
||||
? actionGridControlClassName
|
||||
: layout === 'responsive'
|
||||
? actionResponsiveControlClassName
|
||||
: actionStripControlClassName,
|
||||
isInert && 'cursor-default',
|
||||
)
|
||||
const aria = {
|
||||
|
|
|
|||
|
|
@ -138,8 +138,8 @@ export const CampaignHomeActionStrip = ({
|
|||
isStripLayout
|
||||
? 'flex min-w-max snap-x snap-proximity gap-0 px-4 pb-1 md:px-0'
|
||||
: 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',
|
||||
? 'grid grid-cols-3 gap-2'
|
||||
: 'grid grid-cols-3 gap-2 md:flex md:min-w-max md:snap-x md:snap-proximity md:gap-0 md:px-0 md:pb-1',
|
||||
)
|
||||
|
||||
const scrollerClassName = cn(
|
||||
|
|
@ -160,12 +160,16 @@ export const CampaignHomeActionStrip = ({
|
|||
className={scrollerClassName}
|
||||
{...finePointerPanProps}
|
||||
>
|
||||
<ul role="list" className={listClassName}>
|
||||
<ul
|
||||
role="list"
|
||||
className={listClassName}
|
||||
data-layout={!isStripLayout ? 'grid-3' : undefined}
|
||||
>
|
||||
{actions?.map(({ id, ...button }) => (
|
||||
<li key={id ?? button.href ?? button.label} className="m-0 list-none p-0">
|
||||
<li key={id ?? button.href ?? button.label} className="m-0 min-w-0 list-none p-0">
|
||||
<CampaignHomeActionButton
|
||||
{...button}
|
||||
layout={isStripLayout ? 'strip' : 'responsive'}
|
||||
layout={isStripLayout ? 'strip' : isGridLayout ? 'grid' : 'responsive'}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import type { Page } from '@playwright/test'
|
|||
|
||||
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '../../src/lib/campaignWizardCopy.js'
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
import {
|
||||
assertThreeColumnActionGrid,
|
||||
collectActionBoundingBoxes,
|
||||
} from './helpers/actionGridGeometry.js'
|
||||
|
||||
const staffActionLabels = [
|
||||
'Ajustar votos',
|
||||
|
|
@ -127,6 +131,11 @@ test.describe('Início — catálogo de ações (B45)', () => {
|
|||
expect(overflowX).not.toBe('auto')
|
||||
expect(overflowX).not.toBe('scroll')
|
||||
|
||||
const actionList = actionsRegion.locator('ul[data-layout="grid-3"]')
|
||||
await expect(actionList).toBeVisible()
|
||||
const boxes = await collectActionBoundingBoxes(actionsRegion, staffActionLabels)
|
||||
assertThreeColumnActionGrid(boxes, 2)
|
||||
|
||||
await page.getByRole('link', { name: 'Ajustar votos', exact: true }).click()
|
||||
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos/)
|
||||
await expect(page.getByLabel('Buscar município')).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import {
|
|||
expectPostResponse,
|
||||
test,
|
||||
} from './fixtures/campaignE2EFixtures.js'
|
||||
import {
|
||||
assertThreeColumnActionGrid,
|
||||
collectActionBoundingBoxes,
|
||||
} from './helpers/actionGridGeometry.js'
|
||||
|
||||
/**
|
||||
* Core municipality-model journeys per role: coordinator strategy editing, advisor
|
||||
|
|
@ -483,6 +487,43 @@ test.describe('Municípios — FAB ações rápidas mobile (B126)', () => {
|
|||
test.describe('Municípios — FAB overlay polish (B126)', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
const staffActionLabels = [
|
||||
'Ajustar votos',
|
||||
'Registrar sinal',
|
||||
'Mudar tendência',
|
||||
'Atualizar liderança',
|
||||
'Registrar pedido',
|
||||
'Ver esquecidos',
|
||||
] as const
|
||||
|
||||
test('overlay actions use a 3×2 grid at mobile width (B136)', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenador B136'),
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Ações rápidas' }).click()
|
||||
const overlay = page.locator('#CampaignQuickActionsOverlay')
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
const actionsRegion = overlay.getByLabel('Ações rápidas')
|
||||
await expect(actionsRegion.locator('ul[data-layout="grid-3"]')).toBeVisible()
|
||||
for (const label of staffActionLabels) {
|
||||
await expect(
|
||||
actionsRegion
|
||||
.getByRole('link', { name: label, exact: true })
|
||||
.or(actionsRegion.getByRole('button', { name: label, exact: true })),
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
const boxes = await collectActionBoundingBoxes(actionsRegion, staffActionLabels)
|
||||
assertThreeColumnActionGrid(boxes, 2)
|
||||
})
|
||||
|
||||
test('overlay labels readable and search focus hides action strip', async ({
|
||||
campaign,
|
||||
page,
|
||||
|
|
|
|||
107
tests/e2e/helpers/actionGridGeometry.ts
Normal file
107
tests/e2e/helpers/actionGridGeometry.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
const ROW_Y_TOLERANCE_PX = 12
|
||||
const COLUMN_X_TOLERANCE_PX = 24
|
||||
|
||||
export type ActionBoundingBox = {
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export async function collectActionBoundingBoxes(
|
||||
container: Locator,
|
||||
labels: readonly string[],
|
||||
): Promise<ActionBoundingBox[]> {
|
||||
const boxes: ActionBoundingBox[] = []
|
||||
|
||||
for (const label of labels) {
|
||||
const control = container
|
||||
.getByRole('link', { name: label, exact: true })
|
||||
.or(container.getByRole('button', { name: label, exact: true }))
|
||||
const box = await control.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error(`Missing bounding box for action "${label}"`)
|
||||
}
|
||||
boxes.push({ label, ...box })
|
||||
}
|
||||
|
||||
return boxes
|
||||
}
|
||||
|
||||
function groupActionsByRow(
|
||||
boxes: readonly ActionBoundingBox[],
|
||||
tolerancePx = ROW_Y_TOLERANCE_PX,
|
||||
): ActionBoundingBox[][] {
|
||||
const sorted = [...boxes].sort((left, right) => left.y - right.y || left.x - right.x)
|
||||
const rows: ActionBoundingBox[][] = []
|
||||
|
||||
for (const box of sorted) {
|
||||
const row = rows.find((entry) => Math.abs(entry[0]!.y - box.y) <= tolerancePx)
|
||||
if (row) {
|
||||
row.push(box)
|
||||
} else {
|
||||
rows.push([box])
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
row.sort((left, right) => left.x - right.x)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function clusterActionsByColumn(
|
||||
boxes: readonly ActionBoundingBox[],
|
||||
tolerancePx = COLUMN_X_TOLERANCE_PX,
|
||||
): ActionBoundingBox[][] {
|
||||
const sorted = [...boxes].sort((left, right) => left.x - right.x)
|
||||
const columns: ActionBoundingBox[][] = []
|
||||
|
||||
for (const box of sorted) {
|
||||
const column = columns.find((entry) => Math.abs(entry[0]!.x - box.x) <= tolerancePx)
|
||||
if (column) {
|
||||
column.push(box)
|
||||
} else {
|
||||
columns.push([box])
|
||||
}
|
||||
}
|
||||
|
||||
for (const column of columns) {
|
||||
column.sort((left, right) => left.y - right.y)
|
||||
}
|
||||
|
||||
return columns.sort((left, right) => left[0]!.x - right[0]!.x)
|
||||
}
|
||||
|
||||
/** Asserts a 3-column grid with `expectedRowCount` rows (staff = 2 rows of 3). */
|
||||
export function assertThreeColumnActionGrid(
|
||||
boxes: readonly ActionBoundingBox[],
|
||||
expectedRowCount: number,
|
||||
) {
|
||||
expect(boxes).toHaveLength(expectedRowCount * 3)
|
||||
|
||||
const columns = clusterActionsByColumn(boxes)
|
||||
expect(columns).toHaveLength(3)
|
||||
for (const column of columns) {
|
||||
expect(column).toHaveLength(expectedRowCount)
|
||||
}
|
||||
|
||||
const medianHeight = [...boxes].map((box) => box.height).sort((left, right) => left - right)[
|
||||
Math.floor(boxes.length / 2)
|
||||
]!
|
||||
const rowTolerance = Math.max(ROW_Y_TOLERANCE_PX, medianHeight * 0.35)
|
||||
const rows = groupActionsByRow(boxes, rowTolerance)
|
||||
expect(rows).toHaveLength(expectedRowCount)
|
||||
|
||||
for (const row of rows) {
|
||||
expect(row).toHaveLength(3)
|
||||
const [first, second, third] = row
|
||||
expect(second.x).toBeGreaterThan(first.x + first.width * 0.4)
|
||||
expect(third.x).toBeGreaterThan(second.x + second.width * 0.4)
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +98,15 @@ describe('CampaignHomeActionButton', () => {
|
|||
expect(control.className).toContain('w-[5.5rem]')
|
||||
})
|
||||
|
||||
it('uses full-width grid sizing when layout is grid (B136)', () => {
|
||||
renderActionButton({ label: 'Registrar', icon: BarChart3, layout: 'grid' })
|
||||
const control = screen.getByRole('button', { name: 'Registrar' })
|
||||
expect(control.className).toContain('w-full')
|
||||
expect(control.className).toContain('min-w-0')
|
||||
expect(control.className).not.toContain('box-content')
|
||||
expect(control.className).not.toContain('w-[5.5rem]')
|
||||
})
|
||||
|
||||
it('opens the description drawer on long-press when the pointer is coarse', () => {
|
||||
resetCampaignCoarsePointerForTests()
|
||||
matchMediaMock.mockImplementation((query: string) => ({
|
||||
|
|
@ -209,7 +218,9 @@ describe('CampaignHomeActionStrip', () => {
|
|||
const list = region?.querySelector('ul[role="list"]')
|
||||
expect(list).toBeTruthy()
|
||||
expect(list?.className).toMatch(/grid-cols-3/)
|
||||
expect(list?.getAttribute('data-layout')).toBe('grid-3')
|
||||
expect(list?.className).toMatch(/md:flex/)
|
||||
expect(list?.querySelector('li')?.className).toContain('min-w-0')
|
||||
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)
|
||||
|
|
@ -229,6 +240,7 @@ describe('CampaignHomeActionStrip', () => {
|
|||
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?.getAttribute('data-layout')).toBe('grid-3')
|
||||
expect(list?.className).not.toMatch(/md:flex/)
|
||||
expect(region?.className).not.toMatch(/overflow-x-auto/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ describe('CampaignQuickActionsOverlay (B126)', () => {
|
|||
const chrome = document.querySelector('[data-slot="quick-actions-chrome"]')
|
||||
const list = chrome?.querySelector('ul[role="list"]')
|
||||
expect(list?.className).toContain('grid-cols-3')
|
||||
expect(list?.getAttribute('data-layout')).toBe('grid-3')
|
||||
expect(list?.className).not.toMatch(/flex/)
|
||||
expect(chrome?.querySelector('[aria-label="Ações rápidas"]')).not.toBeNull()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -329,6 +329,23 @@ describe('list omnibox adapters (B128)', () => {
|
|||
expect(cleared).toEqual({ kind: 'clear', state: {} })
|
||||
})
|
||||
|
||||
it('search-only omnibox clears and removes chips', () => {
|
||||
expect(buildSearchOnlyOmniboxSuggestions('')).toEqual([])
|
||||
|
||||
const removed = removeSearchOnlyOmniboxChip({
|
||||
state: { q: 'x', page: 2 },
|
||||
chipId: 'q',
|
||||
withPageReset: (next) => ({ ...next, page: 1 }),
|
||||
})
|
||||
expect(removed).toEqual({ kind: 'url', state: { page: 1, q: undefined } })
|
||||
|
||||
const cleared = clearSearchOnlyOmnibox({
|
||||
state: { q: 'x' },
|
||||
cleared: {},
|
||||
})
|
||||
expect(cleared).toEqual({ kind: 'clear', state: {} })
|
||||
})
|
||||
|
||||
it('advisor toggles municipality portfolio filter and suggests by name', () => {
|
||||
const base = parseAdvisorListParams({})
|
||||
const applied = applyAdvisorOmniboxSuggestion({
|
||||
|
|
|
|||
Loading…
Reference in a new issue