B107 — wizard município sem título, busca sticky e skeleton

Remove the municipality step h1 via optional stepTitle on CampaignWizardShell.
Keep search input sticky under the wizard header, show hit-row skeletons
while suggest/search loads, and tighten mobile top padding when titleless.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 18:36:30 +00:00
parent de9377de85
commit fc465639f2
No known key found for this signature in database
5 changed files with 236 additions and 29 deletions

View file

@ -0,0 +1,131 @@
# Wizard município — sem título + busca sticky + skeleton
Status: registrado
Atualizado em: 2026-08-01
Issue: #142
Priority: P1
Model: composer-2.5
Impeccable: B — encaixe em `WizardMunicipalitySearchStep` + `CampaignWizardShell` (título opcional)
Appetite: ~0,5–0,75d eng; markup/CSS + shell opt-out do h1 + skeleton idle; sem migration
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Clarity under pressure) / `DESIGN.md` · B60/B75/B95 · tema `campaign`.
Na implementação: craft compacto → critique → polish (teclado aberto + lista longa).
Brief compacto:
- **Persona / contexto:** CG/assessor no passo 1 do wizard no celular; o header vermelho já diz o fluxo (“Ajustar votos”); o placeholder é “Nome do município…”.
- **Job principal:** cair direto na busca (sem h1 “Em qual município?”); ao rolar hits, a barra de busca **permanece** logo abaixo do header; enquanto suggest/search carrega, skeleton shimmer na região de resultados.
- **Estratégia de cor:** inalterada; sticky com `bg-background` para não transparizar hits; skeleton `bg-muted` / `animate-pulse`.
- **Edit where you see:** não — layout do passo.
- **Anti-goals:** sticky do header vermelho (já fora do scroll); redesign da hit row; mudar ranking B92–B94.
### Wireframe (texto)
```text
┌─ header wizard (Mandate Red) ───────────────────────┐
│ Ajustar votos [X] │
└─────────────────────────────────────────────────────┘
┌─ scroll content ────────────────────────────────────┐
│ ┌─ Buscar município (sticky) ────────────────────┐ │ ← cola sob o header
│ │ Nome do município… │ │
│ └────────────────────────────────────────────────┘ │
│ ░░░ skeleton · skeleton · skeleton ░░░ (loading) │
│ Cairu · Baixo Sul (após success) │
│ (rola; busca sticky fica) │
└─────────────────────────────────────────────────────┘
Sem h1 “Em qual município?”. Gap shell→busca mínimo no mobile.
```
## Dados → decisão → apresentação
Dados: N/A — layout/chrome; hits inalterados.
## Contexto
**B95 ✓** aproxima o título do header e remove bullets; o h1 **“Em qual município?”** (`WIZARD_MUNICIPALITY_STEP_TITLE`) permanece. `CampaignWizardShell` sempre renderiza o `h1` e foca nele no mount.
No mobile, a busca + lista rolam juntos dentro de `CampaignContentScroll` — a barra sobe e some sob o header ao escanear sugestões/hits.
Pedido de produto (2026-08-01): remover o título; diminuir gap ao header; busca sticky/fixed sempre sob o header; skeleton enquanto o empty/suggest (ou search) carrega — se tem load, tem load indicator.
## Objetivos
- Passo município **sem** `h1` “Em qual município?” (copy pode permanecer no módulo para sr-only/`aria-label` da região se útil; UI visível some).
- Gap visual header→busca no mobile menor que o stack atual (sem h1 + `pt` já `pt-3` do B95; critique pode `pt-2` / `gap` só neste passo).
- Barra de busca **sticky** no scrollport do conteúdo (`sticky top-0 z-10` + fundo opaco + padding-bottom curto), sempre visível sob o top bar vermelho.
- Lista de resultados rola por baixo da busca sticky.
- Enquanto `results.status === 'loading'` / `resultsBusy` sem hits: 3 rows skeleton (paridade B106), `aria-busy` mantido; sumir em `success`/`error`.
- Sem migration / mudança de ranking / autofocus (autofocus → **B108**).
- Pin: unit/RTL se classes sticky/`stepTitle` opcional forem pináveis; e2e não obrigatório.
## Decisões travadas
- **`stepTitle` opcional no shell (`string | null`): omitir `h1` quando vazio/null.** Passo município passa `null`. Outros passos inalterados. **Rejeitado:** CSS `hidden` no h1 (ainda no a11y tree / foco); fork de shell só para município.
- **Sticky da busca, não `position: fixed` no viewport.** Sticky relativo ao `CampaignContentScroll` alinha sob o header sem calcular `env(safe-area)` + altura do top bar à mão. **Rejeitado:** `fixed` + spacer mágico (frágil com teclado).
- **Skeleton no idle/loading deste passo (produto 2026-08-01).** Paridade com B106; reutilizar o mesmo bloco de rows se B106 extrair helper — 2º call site justifica shared mínimo. **Rejeitado:** só `aria-busy` sem visual; spinner centrado.
- **Item novo (B107), não reabrir B95.** B95 manteve o título de propósito; produto agora remove. **Rejeitado:** editar só o as-built B95 sem Issue.
- **i18n:** `WIZARD_MUNICIPALITY_STEP_TITLE` pode virar sr-only / aria da região; identificadores ingleses intactos.
## Questões em aberto
- **Título some só no mobile ou em todo breakpoint?** **Opções:** A some sempre | B some só `md:hidden` (desktop mantém h1). **Recomendação:** **A** — contexto do fluxo já está no chrome/desktop trailing; placeholder basta. _(assumido — gate: aprovado implícito no lote)_
## Abordagem proposta
```mermaid
flowchart TB
Shell["CampaignWizardShell stepTitle=null → sem h1"]
Step["WizardMunicipalitySearchStep"]
Stick["wrapper sticky top-0 bg-background"]
Input["CampaignSearchInput"]
Skel["skeleton rows se loading"]
List["ul hits / suggest"]
Shell --> Step
Step --> Stick --> Input
Step --> Skel
Step --> List
```
Componentes:
- **`CampaignWizardShell.tsx`:** `stepTitle?: string | null`; se ausente, não renderiza `h1` / não foca título; `aria-labelledby` cai para id do flow no chrome ou `aria-label` no `main` via `flowTitle`.
- **`WizardMunicipalitySearchStep.tsx`:** `stepTitle={null}`; envolver input (e label) em container sticky; lista fora do sticky; render skeleton quando busy sem hits.
- **Skeleton rows:** reusar helper/componente nascido em **B106** (`HomeSearchSuggestSkeleton` ou equivalente em `shared`/`dashboard`) — se B107 landar antes, inline 3× `Skeleton` e B106 extrai no polish.
- **`campaignWizardCopy.ts`:** título pode permanecer exportado para aria; documentar.
- **Migration:** Sem migration.
## Dependências
- Soft: B60 ✓, B75 ✓, B95 ✓. Nenhuma dura.
- Coordenar com **B108** (autofocus): sem h1, o shell não deve “roubar” foco — B108 formaliza; neste item, ao omitir h1, **não** chamar `titleRef.focus()` (já coberto pela ausência do nó).
## Não escopo
- Autofocus do input → **B108**.
- Destaque de cenários de votos → **B108**.
- Voltar + skeleton do **Início** → **B106** (shared de rows ok).
- Ranking/geo/continuity → B92–B94.
## Rabbit holes
- **Sticky + teclado virtual (iOS) empurra visualViewport.** **Mitigação:** sticky simples v1; se critique mostrar jump, medir — não `visualViewport` polyfill neste appetite.
- **Tornar todo passo “title-less”.** **Mitigação:** só município; outros passos mantêm pergunta no h1.
## Adiado com gatilho
- **`position: sticky` no dock do Início.** Só se produto pedir paridade (hoje a busca já está no fundo).
## Referências
- GitHub Issue #142
- `src/components/campaign/shared/WizardMunicipalitySearchStep.tsx`
- `src/components/campaign/shared/CampaignWizardShell.tsx`
- `src/lib/campaignWizardCopy.ts`
- [polimento-busca-municipio-wizard.md](polimento-busca-municipio-wizard.md) (B95) · [busca-municipio-wizard.md](busca-municipio-wizard.md) (B60) · [header-mobile-wizard-campanha.md](header-mobile-wizard-campanha.md) (B75)
- `PRODUCT.md` / `DESIGN.md`
Qualidade de decisão: 5/5

View file

@ -0,0 +1,25 @@
import { Skeleton } from '@/components/ui/skeleton'
import { HOME_SEARCH_HIT_ROW_WRAPPER_CLASS } from '@/lib/homeSearchUi'
const HOME_SEARCH_HIT_ROW_SKELETON_PRIORITY_CLASS = 'h-4 w-4 shrink-0 rounded-sm'
const HOME_SEARCH_HIT_ROW_SKELETON_PRIMARY_CLASS = 'h-4 w-40 max-w-full'
const HOME_SEARCH_HIT_ROW_SKELETON_SECONDARY_CLASS = 'h-3 w-28 max-w-full'
export const HomeSearchHitRowsSkeleton = ({ count = 3 }: { count?: number }) => (
<ul className="m-0 list-none p-0" aria-hidden="true">
{Array.from({ length: count }, (_, index) => (
<li key={index}>
<div className={HOME_SEARCH_HIT_ROW_WRAPPER_CLASS}>
<div className="flex min-h-11 items-center gap-3 py-2.5">
<Skeleton className={HOME_SEARCH_HIT_ROW_SKELETON_PRIORITY_CLASS} />
<div className="min-w-0 flex-1 space-y-1">
<Skeleton className={HOME_SEARCH_HIT_ROW_SKELETON_PRIMARY_CLASS} />
<Skeleton className={HOME_SEARCH_HIT_ROW_SKELETON_SECONDARY_CLASS} />
</div>
<Skeleton className="h-4 w-8 shrink-0 tabular-nums" />
</div>
</div>
</li>
))}
</ul>
)

View file

@ -9,12 +9,15 @@ import {
type CampaignWizardChromeSkip,
} from '@/components/campaign/shell/CampaignWizardChromeContext'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { WIZARD_STEP_PENDING_MESSAGE } from '@/lib/campaignWizardCopy'
import {
WIZARD_STEP_PENDING_MESSAGE,
wizardFlowChromeAriaLabel,
} from '@/lib/campaignWizardCopy'
import { cn } from '@/lib/utils'
export type CampaignWizardShellProps = {
flowTitle: string
stepTitle: string
stepTitle?: string | null
isEntryStep: boolean
previousHref: string
dismissHref?: string
@ -37,6 +40,7 @@ export const CampaignWizardShell = ({
contentAlign = 'start',
children,
}: CampaignWizardShellProps) => {
const hasStepTitle = stepTitle != null && stepTitle !== ''
const titleRef = useRef<HTMLHeadingElement>(null)
const titleId = useId()
@ -56,24 +60,28 @@ export const CampaignWizardShell = ({
useSetCampaignWizardChrome(chrome)
useEffect(() => {
if (!hasStepTitle) return
titleRef.current?.focus()
}, [stepTitle])
}, [hasStepTitle, stepTitle])
return (
<div className="flex min-h-full w-full flex-col">
{trailingAction ? <div className="hidden justify-end md:flex">{trailingAction}</div> : null}
<main
aria-labelledby={titleId}
aria-labelledby={hasStepTitle ? titleId : undefined}
aria-label={hasStepTitle ? undefined : wizardFlowChromeAriaLabel(flowTitle)}
className={cn(
'flex flex-1 flex-col pt-3 pb-6 md:justify-start md:py-6',
'flex flex-1 flex-col pb-6 md:justify-start md:py-6',
hasStepTitle ? 'pt-3' : 'pt-2',
contentAlign === 'end' ? 'justify-end md:justify-start' : 'justify-start',
)}
>
<CampaignListResults
pendingMessage={WIZARD_STEP_PENDING_MESSAGE}
className={cn(
'flex flex-1 flex-col gap-6',
'flex flex-1 flex-col',
hasStepTitle ? 'gap-6' : 'gap-0',
contentAlign === 'end' ? 'justify-end md:justify-start' : 'justify-start',
)}
>
@ -82,14 +90,16 @@ export const CampaignWizardShell = ({
{municipalityLabel}
</p>
) : null}
<h1
ref={titleRef}
id={titleId}
tabIndex={-1}
className="text-xl font-semibold tracking-tight outline-none md:text-2xl"
>
{stepTitle}
</h1>
{hasStepTitle ? (
<h1
ref={titleRef}
id={titleId}
tabIndex={-1}
className="text-xl font-semibold tracking-tight outline-none md:text-2xl"
>
{stepTitle}
</h1>
) : null}
{children}
</CampaignListResults>
</main>

View file

@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { HomeSearchHitRowsSkeleton } from '@/components/campaign/dashboard/HomeSearchHitRowsSkeleton'
import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashboard/HomeSearchMunicipalityVoteTrailing'
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
@ -170,30 +171,35 @@ export const WizardMunicipalitySearchStep = ({
results.mode === 'search' &&
results.municipalities.length === 0
const showSkeleton =
resultsBusy && displayRows.length === 0 && results.status !== 'error' && !showEmpty
return (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE}
stepTitle={null}
isEntryStep
previousHref={previousHref}
dismissHref={previousHref}
>
<div className="flex flex-col gap-2">
<CampaignSearchInput
id={WIZARD_MUNICIPALITY_SEARCH_INPUT_ID}
label={WIZARD_MUNICIPALITY_SEARCH_LABEL}
placeholder={WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER}
value={query.raw}
onChange={(event) => setRaw(event.target.value)}
autoComplete="off"
enterKeyHint="search"
aria-busy={resultsBusy || undefined}
maxLength={HOME_SEARCH_QUERY_MAX_LENGTH}
/>
<div className="flex flex-col">
<div className="sticky top-0 z-10 -mx-4 bg-background px-4 pb-2 md:-mx-6 md:px-6">
<CampaignSearchInput
id={WIZARD_MUNICIPALITY_SEARCH_INPUT_ID}
label={WIZARD_MUNICIPALITY_SEARCH_LABEL}
placeholder={WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER}
value={query.raw}
onChange={(event) => setRaw(event.target.value)}
autoComplete="off"
enterKeyHint="search"
aria-busy={resultsBusy || undefined}
maxLength={HOME_SEARCH_QUERY_MAX_LENGTH}
/>
</div>
<div
role="region"
aria-live="polite"
aria-label="Resultados da busca"
aria-label={WIZARD_MUNICIPALITY_STEP_TITLE}
aria-busy={resultsBusy || undefined}
className="min-w-0"
>
@ -202,6 +208,7 @@ export const WizardMunicipalitySearchStep = ({
{results.message}
</p>
) : null}
{showSkeleton ? <HomeSearchHitRowsSkeleton /> : null}
{results.status === 'success' && displayRows.length > 0 ? (
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
{displayRows.map(({ hit, continuityReason }) => (

View file

@ -12,7 +12,10 @@ import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizard
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { SidebarProvider } from '@/components/ui/Sidebar'
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '@/lib/campaignWizardCopy'
import {
WIZARD_MUNICIPALITY_STEP_TITLE,
wizardFlowChromeAriaLabel,
} from '@/lib/campaignWizardCopy'
import { stubMatchMedia } from '../helpers/matchMedia'
const renderWizardShell = (props: React.ComponentProps<typeof CampaignWizardShell>) =>
@ -86,6 +89,37 @@ describe('CampaignWizardShell', () => {
expect(main.getAttribute('aria-labelledby')).toBe(heading.id)
})
it('omits step heading when stepTitle is null and labels main with flow title', () => {
const { container } = renderWizardShell({
flowTitle: 'Ajustar votos',
stepTitle: null,
isEntryStep: true,
previousHref: '/campanha',
children: 'Corpo',
})
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
const main = within(container).getByRole('main')
expect(main.getAttribute('aria-labelledby')).toBeNull()
expect(main.getAttribute('aria-label')).toBe(wizardFlowChromeAriaLabel('Ajustar votos'))
})
it('uses tighter top padding on mobile when step title is omitted', () => {
const { container } = renderWizardShell({
flowTitle: 'Ajustar votos',
stepTitle: null,
isEntryStep: true,
previousHref: '/campanha',
children: null,
})
const main = container.querySelector('main')
expect(main?.className).toMatch(/pt-2/)
expect(main?.className).not.toMatch(/pt-3/)
expect(main?.className).toMatch(/pb-6/)
expect(main?.className).toMatch(/md:py-6/)
})
it('moves focus to the step title when stepTitle changes', () => {
const { rerender } = render(
<SidebarProvider>