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:
parent
de9377de85
commit
fc465639f2
5 changed files with 236 additions and 29 deletions
131
docs/plans/wizard-municipio-sem-titulo-sticky.md
Normal file
131
docs/plans/wizard-municipio-sem-titulo-sticky.md
Normal 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
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue