diff --git a/docs/plans/wizard-municipio-sem-titulo-sticky.md b/docs/plans/wizard-municipio-sem-titulo-sticky.md new file mode 100644 index 00000000..c6caea81 --- /dev/null +++ b/docs/plans/wizard-municipio-sem-titulo-sticky.md @@ -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 diff --git a/src/components/campaign/dashboard/HomeSearchHitRowsSkeleton.tsx b/src/components/campaign/dashboard/HomeSearchHitRowsSkeleton.tsx new file mode 100644 index 00000000..64623647 --- /dev/null +++ b/src/components/campaign/dashboard/HomeSearchHitRowsSkeleton.tsx @@ -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 }) => ( + +) diff --git a/src/components/campaign/shared/CampaignWizardShell.tsx b/src/components/campaign/shared/CampaignWizardShell.tsx index 053e6abb..c6908103 100644 --- a/src/components/campaign/shared/CampaignWizardShell.tsx +++ b/src/components/campaign/shared/CampaignWizardShell.tsx @@ -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(null) const titleId = useId() @@ -56,24 +60,28 @@ export const CampaignWizardShell = ({ useSetCampaignWizardChrome(chrome) useEffect(() => { + if (!hasStepTitle) return titleRef.current?.focus() - }, [stepTitle]) + }, [hasStepTitle, stepTitle]) return (
{trailingAction ?
{trailingAction}
: null}
@@ -82,14 +90,16 @@ export const CampaignWizardShell = ({ {municipalityLabel}

) : null} -

- {stepTitle} -

+ {hasStepTitle ? ( +

+ {stepTitle} +

+ ) : null} {children}
diff --git a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx index 85fd3c77..34a98a1c 100644 --- a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx +++ b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx @@ -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 ( -
- setRaw(event.target.value)} - autoComplete="off" - enterKeyHint="search" - aria-busy={resultsBusy || undefined} - maxLength={HOME_SEARCH_QUERY_MAX_LENGTH} - /> +
+
+ setRaw(event.target.value)} + autoComplete="off" + enterKeyHint="search" + aria-busy={resultsBusy || undefined} + maxLength={HOME_SEARCH_QUERY_MAX_LENGTH} + /> +
@@ -202,6 +208,7 @@ export const WizardMunicipalitySearchStep = ({ {results.message}

) : null} + {showSkeleton ? : null} {results.status === 'success' && displayRows.length > 0 ? (
    {displayRows.map(({ hit, continuityReason }) => ( diff --git a/tests/unit/campaignWizardShell.unit.spec.tsx b/tests/unit/campaignWizardShell.unit.spec.tsx index 61b7dadb..73922723 100644 --- a/tests/unit/campaignWizardShell.unit.spec.tsx +++ b/tests/unit/campaignWizardShell.unit.spec.tsx @@ -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) => @@ -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(