feat(campanha): B127 omnibox filter bar chassis + Municípios pilot

Replace the stacked município filter controls with a shared omnibox
(chips + suggestions) backed by the frozen list URL contract; scenario
and non-default sort appear as presentation chips.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 19:07:54 +00:00
parent 544292f8f4
commit 21e20a6719
No known key found for this signature in database
9 changed files with 1033 additions and 251 deletions

View file

@ -0,0 +1,108 @@
# Impl: B127 — Barra omnibox de filtros nas listas `/campanha`
Status: aprovado
Atualizado em: 2026-08-02
Issue: #264
Intenção: docs/plans/barra-filtros-omnibox-listas.md
Appetite restante: piloto Municípios + chassis compartilhado (B128 adota depois)
## Leitura da intenção
- **Outcome:** Uma barra omnibox (chips + caret + sugestões) é a entrada primária de filtro em Municípios em qualquer viewport; URL do recorte permanece a fonte de verdade; header filters e B18 sidebar ficam.
- **O que NÃO negociar:** contrato de URL B18; cenário fora do href/bookmark; leader lockdown; semântica inclusivo/exclusivo/texto do mapa de produto; não inventar dimensões.
- **O que reavaliar:** hipótese de “só trocar MunicipalityFilters” — precisa de chassis reutilizável (B128) + builders puros de chips/sugestões no domínio, sem terceiro caminho de estado.
## Abordagem recomendada
```mermaid
flowchart LR
URL[URL recorte] --> Chips[buildMunicipalityOmniboxChips]
Query[draft query] --> Sugs[buildMunicipalityOmniboxSuggestions]
Facets[facets servidor] --> Sugs
Chips --> UI[CampaignListOmnibox]
Sugs --> UI
UI -->|select/remove/clear| Apply[apply/remove municipality omnibox]
Apply -->|url state| Nav[useCampaignListFilterNavigation]
Apply -->|scenario| Ctx[MunicipalityEstimateScenarioContext]
Header[Header filters] --> URL
```
**Opções consideradas:**
| | A — Chassis genérico + adapter Municípios | B — Reescrever só MunicipalityFilters sem chassis | C — cmdk CommandDialog global + filtros |
| --- | --- | --- | --- |
| | Tipos/UI em `shared` + `lib`; domínio monta chips/sugestões/ações | Tudo no arquivo do piloto | Palette global misturando home search |
**Recomendação:** A — porque B128 precisa do mesmo gesto; deep module (UI burra + builders puros); evita twin na adoção.
**Rejeitadas:** B porque a segunda lista copia 200 linhas; C viola anti-goal “command palette global”.
### Decisões de engenharia
1. **Fonte de verdade**
Opções: URL-only | URL + React espelho | Context unificado.
**Rec:** URL para filtros/sort/`q`; scenario continua no context existente (não URL).
**Rejeitadas:** Context unificado (duplica B18); scenario na URL (quebra bookmarks).
2. **Onde vive o matching de sugestões**
Opções: só no client UI | pure em `lib`/`utilities` do domínio.
**Rec:** pure `municipalityOmnibox.ts` (utilities, client-safe) + helper genérico mínimo em `lib/campaignListOmnibox.ts` (tipos + match includes via `normalizeSearchPhrase`).
**Rejeitadas:** lógica só no JSX (intestável); DSL cross-domínio agora (1 call site piloto — B128 extrai se precisar).
3. **Confirmação de texto livre**
Opções: debounce automático (status quo) | Enter/sugestão “Busca: …” | ambos.
**Rec:** Enter ou escolha da sugestão “Busca: {texto}” confirma chip; **sem** debounce que muda `q` a cada tecla — a digitação alimenta sugestões; o chip é o recorte.
**Rejeitadas:** só debounce (contradiz “confirmado”); manter debounce + chip (dois estados de texto).
4. **Chips de apresentação (cenário / sort)**
Opções: sempre visíveis | só quando ≠ default.
**Rec:** só quando ≠ default (cenário ≠ central; sort ≠ deficit+desc). Digitar “cenário”/“ordenar” sugere opções. Remover chip → default.
**Rejeitadas:** sempre visíveis (barra barulhenta no default).
5. **Empty focus**
Opções: listar tudo | nada | atalhos de dimensão.
**Rec:** com query vazia, sugestões = atalhos de dimensão/apresentação (Prioritária, Cenário…, Ordenar…) sem dump de 435 municípios; com texto, filtra valores + “Busca: …”.
**Rejeitadas:** dump completo (inútil em mobile).
### Componentes / mudanças
- **`lib/campaignListOmnibox.ts`**: tipos `CampaignListOmniboxChip` / `Suggestion` + `omniboxQueryMatches` (includes normalizado).
- **`shared/CampaignListOmnibox.tsx`**: input com chips removíveis, popover/cmdk de sugestões agrupadas, Limpar, pending; reusa `useCampaignListTransition` via props `isPending`.
- **`utilities/municipality/municipalityOmnibox.ts`**: `buildMunicipalityOmniboxChips`, `buildMunicipalityOmniboxSuggestions`, `applyMunicipalityOmniboxSuggestion`, `removeMunicipalityOmniboxChip` (resultado discriminado `url` | `scenario` | `noop`).
- **`MunicipalityFilters.tsx`**: substitui pilha (search + cenário solto + mobile selects + sort select) pela omnibox + `SaveMunicipalityFilterControl`; recebe também `slugFilterOptions` (facets).
- **`MunicipalityList.tsx`**: remove `<p>` “Ordenado por …” (caption `sr-only` permanece).
- **`municipios/page.tsx`**: passa facets de slug para filters.
- **Migration:** sem migration.
- **Access / Consent:** inalterado.
- **UI:** Impeccable C — shape→craft na barra existente; tokens `data-theme='campaign'`; touch `min-h-11`.
### Dados → forma
Não aplica (só UI do recorte).
## Fases verificáveis
1. **Tracer** — pure omnibox builders + unit tests (chips/sugestões/apply/remove/cenário/sort).
2. **UI** — `CampaignListOmnibox` + `MunicipalityFilters` piloto; remover pilha e sort label solto.
3. **Gates** — `pnpm gate:fast`; entrega `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Adoção B128 nas outras listas.
- Mover B18 para dentro da omnibox.
- Debounce de busca “como estava” + chip simultâneo.
- Virtualizar 435 sugestões (cap por grupo basta no piloto).
- Redesign do header filter / mapa.
## Riscos e mitigação
- **Dois estados de `q` (draft vs chip):** draft só no input; committed só via apply “Busca”; sync URL→draft só quando não há digitação pendente (mesmo padrão do nav hook, sem debounce de commit).
- **Sidebar B18:** não importar serializador no layout; Save control continua no domínio.
- **Header sync:** ambas as UIs só editam URL → chips refletem no RSC refresh; optimistic opcional nos chips via state prop.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio: unit em builders omnibox + regressão municipalityList filters/URL
**Decision-quality self-score:** 5/5 (rejeitadas nas caras; appetite respeitado; rabbit holes nomeados; reusa URL/nav/header/B18; outcome intacto).

View file

@ -99,6 +99,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
showStaffFilters={isStaffView}
regionFilterOptions={columnFilterOptions.region}
advisorFilterOptions={columnFilterOptions.advisor}
slugFilterValues={columnFilterOptions.name}
/>
)

View file

@ -1,54 +1,25 @@
'use client'
import { useMunicipalityEstimateScenario } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { useMemo, useState } from 'react'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl'
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import {
VOTE_ESTIMATE_SCENARIO_FILTERS_HINT,
VoteEstimateScenarioField,
} from '@/components/campaign/votePledge/VoteEstimateScenarioField'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
import {
buildMunicipalityFilterHref,
clearMunicipalityListFilters,
formatMunicipalityActiveFiltersSummary,
getMunicipalityMultiFilterValues,
municipalityFilterDefinitions,
toggleMunicipalityExclusiveFilterValue,
toggleMunicipalityMultiFilterValue,
municipalityFilterOptionsForSlugs,
type MunicipalityFilterOption,
type MunicipalityMultiFilterParam,
} from '@/utilities/municipality/municipalityListFilters'
import {
municipalityListSortOptions,
parseMunicipalitySortValue,
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
const MunicipalityFilterEstimateScenario = ({
className,
id,
}: {
className?: string
id: string
}) => {
const { scenario, setScenario } = useMunicipalityEstimateScenario()
return (
<VoteEstimateScenarioField
id={id}
value={scenario}
onChange={setScenario}
hint={VOTE_ESTIMATE_SCENARIO_FILTERS_HINT}
className={className}
/>
)
}
applyMunicipalityOmniboxSuggestion,
buildMunicipalityOmniboxChips,
buildMunicipalityOmniboxSuggestions,
clearMunicipalityOmnibox,
removeMunicipalityOmniboxChip,
} from '@/utilities/municipality/municipalityOmnibox'
import type { MunicipalityListState } from '@/utilities/municipality/municipalityListUrl'
type MunicipalityFiltersProps = {
state: MunicipalityListState
@ -57,6 +28,8 @@ type MunicipalityFiltersProps = {
regionFilterOptions: MunicipalityFilterOption[]
/** Advisor options already narrowed by the other active filters. */
advisorFilterOptions: MunicipalityFilterOption[]
/** Facet slugs (labeled from the catalog on the client). */
slugFilterValues?: readonly string[]
}
export const MunicipalityFilters = ({
@ -64,188 +37,87 @@ export const MunicipalityFilters = ({
showStaffFilters,
regionFilterOptions,
advisorFilterOptions,
slugFilterValues = [],
}: MunicipalityFiltersProps) => {
const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } =
useCampaignListFilterNavigation({ state, toHref: buildMunicipalityFilterHref })
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
const activeFiltersSummary = formatMunicipalityActiveFiltersSummary({
...state,
q: draftQ,
const { navigateWithSearch, isPending } = useCampaignListFilterNavigation({
state,
toHref: buildMunicipalityFilterHref,
})
const hasActiveFilters = Boolean(activeFiltersSummary)
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const scenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
const setScenario = scenarioContext?.setScenario
const [query, setQuery] = useState('')
const mobileFilterDefinitions = municipalityFilterDefinitions.filter((definition) => {
if (definition.staffOnly && !showStaffFilters) return false
if (definition.param === 'name') return false
return true
const slugFilterOptions = useMemo(
() => municipalityFilterOptionsForSlugs(slugFilterValues),
[slugFilterValues],
)
const advisorLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of advisorFilterOptions) {
const id = Number(option.value)
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
}
return map
}, [advisorFilterOptions])
const chips = buildMunicipalityOmniboxChips({
state,
scenario,
showStaffFilters,
advisorLabelsById,
})
const suggestions = buildMunicipalityOmniboxSuggestions({
query,
showStaffFilters,
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
})
const runAction = (action: ReturnType<typeof applyMunicipalityOmniboxSuggestion>) => {
if (action.kind === 'scenario') {
setScenario?.(action.scenario)
return
}
if (action.kind === 'clear') {
setScenario?.(action.scenario)
setQuery('')
navigateWithSearch(action.state)
return
}
navigateWithSearch(action.state)
}
return (
<form
role="search"
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
navigateWithSearch(state)
}}
>
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<CampaignSearchInput
id="municipality-search"
label="Buscar município"
placeholder="Buscar por município ou zona…"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
{activeFiltersSummary ? (
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2 md:whitespace-normal">
{activeFiltersSummary}
</p>
) : null}
{showStaffFilters ? (
<MunicipalityFilterEstimateScenario
id="municipality-filter-estimate-scenario"
className="hidden shrink-0 md:block md:self-end"
/>
) : null}
<SaveMunicipalityFilterControl state={state} />
{hasActiveFilters ? (
<Button
type="button"
variant="ghost"
className="min-h-11 shrink-0 md:self-end"
onClick={() => clearSearchAndNavigate(clearMunicipalityListFilters(state))}
>
Limpar
</Button>
) : null}
</div>
<div className="flex flex-col gap-3 md:hidden">
{activeFiltersSummary ? (
<p className="text-sm text-muted-foreground">{activeFiltersSummary}</p>
) : null}
{showStaffFilters ? (
<Field>
<FieldLabel htmlFor="municipality-filter-priority">Prioridade</FieldLabel>
<NativeSelect
id="municipality-filter-priority"
value={state.priority ?? ''}
onChange={(event) => {
navigateWithSearch({
...state,
priority: event.target.value === 'alta' ? 'alta' : undefined,
})
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">Todas</NativeSelectOption>
<NativeSelectOption value="alta">Prioritária</NativeSelectOption>
</NativeSelect>
</Field>
) : null}
{mobileFilterDefinitions.map((definition) => {
if (definition.selection === 'multi') {
let options = definition.options ?? []
if (definition.param === 'region') options = regionFilterOptions
if (definition.param === 'advisor') options = advisorFilterOptions
if (options.length === 0) return null
let selected: string[] = []
if (definition.param !== 'region' && definition.param !== 'advisor') {
selected = getMunicipalityMultiFilterValues(
state,
definition.param as MunicipalityMultiFilterParam,
)
} else if (definition.param === 'region') {
selected = state.regions ?? []
} else if (definition.param === 'advisor') {
selected = (state.advisors ?? []).map(String)
}
return (
<CampaignMobileMultiFilterField
key={definition.param}
id={`municipality-filter-${definition.param}`}
label={definition.label}
emptyLabel={definition.emptyLabel ?? 'Todos'}
options={options}
selected={selected}
onToggle={(value) =>
navigateWithSearch(
toggleMunicipalityMultiFilterValue(
state,
definition.param as MunicipalityMultiFilterParam,
value,
),
)
}
/>
)
}
const value = definition.param === 'coverage' ? (state.coverage ?? '') : ''
return (
<Field key={definition.param}>
<FieldLabel htmlFor={`municipality-filter-${definition.param}`}>
{definition.label}
</FieldLabel>
<NativeSelect
id={`municipality-filter-${definition.param}`}
value={value}
onChange={(event) => {
const selected = event.target.value
if (definition.selection === 'toggle') {
navigateWithSearch(
selected
? toggleMunicipalityExclusiveFilterValue(state, 'coverage', selected)
: { ...state, coverage: undefined },
)
}
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">{definition.allLabel ?? 'Todas'}</NativeSelectOption>
{(definition.options ?? []).map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
)
})}
{showStaffFilters ? (
<MunicipalityFilterEstimateScenario
id="municipality-filter-estimate-scenario-mobile"
className="md:hidden"
/>
) : null}
<Field>
<FieldLabel htmlFor="municipality-sort">Ordenar</FieldLabel>
<NativeSelect
id="municipality-sort"
value={serializeMunicipalitySortValue(activeSort, activeDir)}
onChange={(event) => {
const parsed = parseMunicipalitySortValue(event.target.value)
if (parsed) navigateWithSearch({ ...state, sort: parsed.key, dir: parsed.dir })
}}
className="min-h-11 w-full"
>
{municipalityListSortOptions.map(({ key, dir, label }) => (
<NativeSelectOption
key={serializeMunicipalitySortValue(key, dir)}
value={serializeMunicipalitySortValue(key, dir)}
>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
<CampaignListOmnibox
id="municipality-omnibox"
label="Filtrar municípios"
placeholder="Digite para filtrar (município, território, cenário…)"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyMunicipalityOmniboxSuggestion({ state, suggestionId }))
}}
onRemoveChip={(chipId) => {
runAction(removeMunicipalityOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearMunicipalityOmnibox(state))
}}
trailing={<SaveMunicipalityFilterControl state={state} />}
/>
</form>
)
}

View file

@ -427,10 +427,6 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
return (
<>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<MunicipalityListMobileSection
municipalities={municipalities}
advisorNamesById={advisorNamesById}

View file

@ -0,0 +1,238 @@
'use client'
import { XIcon } from 'lucide-react'
import {
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from 'react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/Command'
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/Popover'
import type {
CampaignListOmniboxChip,
CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import { cn } from '@/lib/utils'
export type CampaignListOmniboxProps = {
id?: string
label: string
placeholder: string
chips: readonly CampaignListOmniboxChip[]
suggestions: readonly CampaignListOmniboxSuggestion[]
query: string
onQueryChange: (query: string) => void
onSelectSuggestion: (suggestionId: string) => void
onRemoveChip: (chipId: string) => void
onClearAll?: () => void
isPending?: boolean
/** Domain controls beside the bar (e.g. save bookmark). */
trailing?: ReactNode
}
/**
* Shared list omnibox chassis (B127): chips inside the field, caret to the
* right, grouped suggestions from the domain adapter. Navigation / URL policy
* stay in the caller.
*/
export const CampaignListOmnibox = ({
id: idProp,
label,
placeholder,
chips,
suggestions,
query,
onQueryChange,
onSelectSuggestion,
onRemoveChip,
onClearAll,
isPending = false,
trailing,
}: CampaignListOmniboxProps) => {
const reactId = useId()
const id = idProp ?? `campaign-list-omnibox-${reactId}`
const listboxId = `${id}-suggestions`
const inputRef = useRef<HTMLInputElement>(null)
const [open, setOpen] = useState(false)
const [activeChipId, setActiveChipId] = useState<string | null>(null)
const hasChips = chips.length > 0
const showSuggestions = open && suggestions.length > 0
useEffect(() => {
if (!open) setActiveChipId(null)
}, [open])
const selectSuggestion = (suggestionId: string) => {
onSelectSuggestion(suggestionId)
onQueryChange('')
setOpen(false)
inputRef.current?.focus()
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
if (open) {
event.preventDefault()
setOpen(false)
}
return
}
if (event.key === 'Backspace' && query.length === 0 && chips.length > 0) {
event.preventDefault()
if (activeChipId) {
onRemoveChip(activeChipId)
setActiveChipId(null)
return
}
const last = chips[chips.length - 1]
if (last) setActiveChipId(last.id)
return
}
if (event.key === 'Enter') {
const trimmed = query.trim()
if (!trimmed) return
event.preventDefault()
// Prefer the explicit Busca suggestion when present; else first match.
const searchSuggestion = suggestions.find((entry) => entry.id === `q:${trimmed}`)
const first = searchSuggestion ?? suggestions[0]
if (first) selectSuggestion(first.id)
}
}
const groups = (() => {
const map = new Map<string, CampaignListOmniboxSuggestion[]>()
for (const suggestion of suggestions) {
const bucket = map.get(suggestion.group) ?? []
bucket.push(suggestion)
map.set(suggestion.group, bucket)
}
return [...map.entries()]
})()
return (
<div
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70 md:flex-row md:items-start"
data-pending={isPending}
aria-busy={isPending}
>
<div className="min-w-0 flex-1">
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-foreground">
{label}
</label>
<Popover
open={showSuggestions}
onOpenChange={(next) => {
if (!next) setOpen(false)
}}
>
<PopoverAnchor asChild>
<div
className={cn(
'flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent px-2 py-1.5 shadow-xs',
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50',
)}
onMouseDown={(event) => {
// Keep focus on the input when clicking chips/padding.
if (event.target === event.currentTarget) inputRef.current?.focus()
}}
>
{chips.map((chip) => (
<span
key={chip.id}
data-active={activeChipId === chip.id ? 'true' : undefined}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground',
'data-[active=true]:ring-2 data-[active=true]:ring-ring',
)}
>
<span className="truncate">{chip.label}</span>
<button
type="button"
className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-background hover:text-foreground"
aria-label={`Remover ${chip.label}`}
onClick={() => {
onRemoveChip(chip.id)
setActiveChipId(null)
inputRef.current?.focus()
}}
>
<XIcon className="size-3.5" />
</button>
</span>
))}
<input
ref={inputRef}
id={id}
role="combobox"
aria-expanded={showSuggestions}
aria-controls={listboxId}
aria-autocomplete="list"
autoComplete="off"
spellCheck={false}
placeholder={hasChips ? '' : placeholder}
value={query}
onChange={(event) => {
setActiveChipId(null)
onQueryChange(event.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
className="min-h-8 min-w-[8rem] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</PopoverAnchor>
<PopoverContent
id={listboxId}
role="listbox"
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[min(100%,24rem)] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false} className="rounded-xl border-0">
<CommandList>
{groups.map(([group, items]) => (
<CommandGroup key={group} heading={group}>
{items.map((suggestion) => (
<CommandItem
key={suggestion.id}
value={suggestion.id}
onSelect={() => selectSuggestion(suggestion.id)}
className="min-h-11"
>
{suggestion.label}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 md:pt-7">
{trailing}
{onClearAll && hasChips ? (
<Button type="button" variant="ghost" className="min-h-11 shrink-0" onClick={onClearAll}>
Limpar
</Button>
) : null}
</div>
</div>
)
}

View file

@ -0,0 +1,34 @@
/**
* Shared omnibox types + query matching for campaign list filter bars (B127).
* Domain adapters build chips/suggestions; the shared UI only renders them.
*/
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
export type CampaignListOmniboxChip = {
id: string
/** Full chip label, e.g. "Busca: Feira" or "Prioritária". */
label: string
}
export type CampaignListOmniboxSuggestion = {
id: string
group: string
label: string
/** Extra haystack tokens (dimension keywords) beyond `label`. */
keywords?: readonly string[]
}
/** Substring match after accent-fold / pt-BR lowercasing. Empty needle matches all. */
export const omniboxQueryMatches = (
haystack: string,
needle: string,
keywords: readonly string[] = [],
): boolean => {
const normalizedNeedle = normalizeSearchPhrase(needle)
if (!normalizedNeedle) return true
if (normalizeSearchPhrase(haystack).includes(normalizedNeedle)) return true
return keywords.some((keyword) => normalizeSearchPhrase(keyword).includes(normalizedNeedle))
}
export const omniboxGroupMatches = (group: string, needle: string): boolean =>
omniboxQueryMatches(group, needle)

View file

@ -0,0 +1,431 @@
/**
* Municipality list omnibox adapter (B127): chips, suggestions and apply/remove
* over the frozen URL contract + presentation scenario. Pure / client-safe.
*/
import {
omniboxGroupMatches,
omniboxQueryMatches,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,
voteEstimateScenarioLabels,
type VoteEstimateScenario,
} from '@/lib/voteEstimate'
import {
clearMunicipalityListFilters,
municipalityFilterDefinitions,
toggleMunicipalityExclusiveFilterValue,
toggleMunicipalityMultiFilterValue,
toggleMunicipalityPriorityFilter,
type MunicipalityFilterOption,
type MunicipalityMultiFilterParam,
} from '@/utilities/municipality/municipalityListFilters'
import {
municipalityListCoverageLabels,
municipalityPriorityLabels,
} from '@/utilities/municipality/municipalityLabels'
import {
defaultMunicipalityListSortDir,
municipalityListSortOptions,
resolveMunicipalityListSort,
type MunicipalityListState,
} from '@/utilities/municipality/municipalityListUrl'
const DEFAULT_MUNICIPALITY_SORT_KEY = 'deficit' as const
const SUGGESTION_CAP_PER_GROUP = 8
export type MunicipalityOmniboxAction =
| { kind: 'url'; state: MunicipalityListState }
| { kind: 'scenario'; scenario: VoteEstimateScenario }
| { kind: 'clear'; state: MunicipalityListState; scenario: VoteEstimateScenario }
const isDefaultMunicipalitySort = (state: MunicipalityListState): boolean => {
const { sort, dir } = resolveMunicipalityListSort(state)
return sort === DEFAULT_MUNICIPALITY_SORT_KEY && dir === defaultMunicipalityListSortDir(sort)
}
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
export const buildMunicipalityOmniboxChips = ({
state,
scenario,
showStaffFilters,
advisorLabelsById,
}: {
state: MunicipalityListState
scenario: VoteEstimateScenario
showStaffFilters: boolean
advisorLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
if (showStaffFilters && state.priority === 'alta') {
chips.push({ id: 'priority', label: municipalityPriorityLabels.alta })
}
for (const slug of state.slugs ?? []) {
chips.push({
id: `slug:${slug}`,
label: chipLabel('Município', getMunicipalityCatalogEntry(slug)?.name ?? slug),
})
}
for (const region of state.regions ?? []) {
chips.push({ id: `region:${region}`, label: chipLabel('Território', region) })
}
if (showStaffFilters) {
for (const advisorId of state.advisors ?? []) {
chips.push({
id: `advisor:${advisorId}`,
label: chipLabel('Assessor', advisorLabelsById.get(advisorId) ?? `Assessor #${advisorId}`),
})
}
if (state.coverage) {
chips.push({
id: `coverage:${state.coverage}`,
label: chipLabel('Assessoria', municipalityListCoverageLabels[state.coverage]),
})
}
for (const trend of state.trends ?? []) {
const definition = municipalityFilterDefinitions.find((entry) => entry.param === 'trend')
const option = definition?.options?.find((entry) => entry.value === trend)
chips.push({
id: `trend:${trend}`,
label: chipLabel('Tendência', option?.label ?? trend),
})
}
for (const territorialClass of state.classes ?? []) {
const definition = municipalityFilterDefinitions.find((entry) => entry.param === 'class')
const option = definition?.options?.find((entry) => entry.value === territorialClass)
chips.push({
id: `class:${territorialClass}`,
label: chipLabel('Classe', option?.label ?? territorialClass),
})
}
for (const level of state.levels ?? []) {
const definition = municipalityFilterDefinitions.find((entry) => entry.param === 'level')
const option = definition?.options?.find((entry) => entry.value === level)
chips.push({
id: `level:${level}`,
label: chipLabel('Nível', option?.label ?? level),
})
}
if (scenario !== DEFAULT_VOTE_ESTIMATE_SCENARIO) {
chips.push({
id: 'scenario',
label: chipLabel('Cenário', voteEstimateScenarioLabels[scenario]),
})
}
}
if (!isDefaultMunicipalitySort(state)) {
const { sort, dir } = resolveMunicipalityListSort(state)
const option = municipalityListSortOptions.find(
(entry) => entry.key === sort && entry.dir === dir,
)
chips.push({
id: 'sort',
label: chipLabel('Ordenação', option?.label ?? `${sort} ${dir}`),
})
}
return chips
}
type SuggestionSeed = CampaignListOmniboxSuggestion & {
/** When true, show even with an empty query (dimension shortcuts). */
emptyQueryVisible?: boolean
}
const pushMatching = (
out: CampaignListOmniboxSuggestion[],
seeds: readonly SuggestionSeed[],
query: string,
): void => {
const needle = query.trim()
const matched: CampaignListOmniboxSuggestion[] = []
for (const seed of seeds) {
const { emptyQueryVisible, ...suggestion } = seed
if (!needle) {
if (emptyQueryVisible) matched.push(suggestion)
continue
}
const groupHit = omniboxGroupMatches(suggestion.group, needle)
const labelHit = omniboxQueryMatches(suggestion.label, needle, suggestion.keywords ?? [])
if (groupHit || labelHit) matched.push(suggestion)
}
const byGroup = new Map<string, CampaignListOmniboxSuggestion[]>()
for (const suggestion of matched) {
const bucket = byGroup.get(suggestion.group) ?? []
if (bucket.length >= SUGGESTION_CAP_PER_GROUP) continue
bucket.push(suggestion)
byGroup.set(suggestion.group, bucket)
}
for (const bucket of byGroup.values()) out.push(...bucket)
}
export const buildMunicipalityOmniboxSuggestions = ({
query,
showStaffFilters,
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
}: {
query: string
showStaffFilters: boolean
regionFilterOptions: readonly MunicipalityFilterOption[]
advisorFilterOptions: readonly MunicipalityFilterOption[]
slugFilterOptions: readonly MunicipalityFilterOption[]
}): CampaignListOmniboxSuggestion[] => {
const suggestions: CampaignListOmniboxSuggestion[] = []
const trimmed = query.trim()
if (trimmed) {
suggestions.push({
id: `q:${trimmed}`,
group: 'Busca',
label: chipLabel('Busca', trimmed),
keywords: ['busca', 'pesquisar', 'texto'],
})
}
const seeds: SuggestionSeed[] = []
if (showStaffFilters) {
seeds.push({
id: 'priority:alta',
group: 'Prioritária',
label: municipalityPriorityLabels.alta,
keywords: ['prioritaria', 'prioridade', 'alta'],
emptyQueryVisible: true,
})
}
for (const option of slugFilterOptions) {
seeds.push({
id: `slug:${option.value}`,
group: 'Município',
label: option.label,
keywords: ['municipio', option.value],
})
}
for (const option of regionFilterOptions) {
seeds.push({
id: `region:${option.value}`,
group: 'Território',
label: option.label,
keywords: ['territorio', 'regiao'],
emptyQueryVisible: false,
})
}
if (showStaffFilters) {
for (const option of advisorFilterOptions) {
seeds.push({
id: `advisor:${option.value}`,
group: 'Assessor',
label: option.label,
keywords: ['assessor', 'assessoria'],
})
}
for (const [value, label] of Object.entries(municipalityListCoverageLabels)) {
seeds.push({
id: `coverage:${value}`,
group: 'Assessoria',
label,
keywords: ['cobertura', 'assessor'],
emptyQueryVisible: true,
})
}
for (const definition of municipalityFilterDefinitions) {
if (definition.param !== 'trend' && definition.param !== 'class' && definition.param !== 'level') {
continue
}
for (const option of definition.options ?? []) {
seeds.push({
id: `${definition.param}:${option.value}`,
group: definition.label,
label: option.label,
keywords: [definition.label.toLowerCase()],
})
}
}
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
seeds.push({
id: `scenario:${scenario}`,
group: 'Cenário',
label: voteEstimateScenarioLabels[scenario],
keywords: ['cenario', 'estimativa', 'cenário'],
emptyQueryVisible: true,
})
}
}
for (const option of municipalityListSortOptions) {
seeds.push({
id: `sort:${option.key}|${option.dir}`,
group: 'Ordenação',
label: option.label,
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
})
}
// Empty focus: surface dimension shortcuts only (no 435-município dump).
if (!trimmed) {
for (const seed of seeds) {
if (!seed.emptyQueryVisible) continue
const { emptyQueryVisible: _visible, ...suggestion } = seed
suggestions.push(suggestion)
}
// Always offer a few sort entry points when idle? Product: type "ordenar".
// Keep empty focus lean — only exclusive/presentation shortcuts above.
return suggestions
}
pushMatching(suggestions, seeds, trimmed)
return suggestions
}
const withPageReset = (state: MunicipalityListState): MunicipalityListState => ({
...state,
page: 1,
})
const applyMultiToggle = (
state: MunicipalityListState,
param: MunicipalityMultiFilterParam,
value: string,
): MunicipalityListState => toggleMunicipalityMultiFilterValue(state, param, value)
export const applyMunicipalityOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: MunicipalityListState
suggestionId: string
}): MunicipalityOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
if (suggestionId === 'priority:alta') {
return { kind: 'url', state: toggleMunicipalityPriorityFilter(state) }
}
if (suggestionId.startsWith('slug:')) {
return { kind: 'url', state: applyMultiToggle(state, 'slug', suggestionId.slice(5)) }
}
if (suggestionId.startsWith('region:')) {
return { kind: 'url', state: applyMultiToggle(state, 'region', suggestionId.slice(7)) }
}
if (suggestionId.startsWith('advisor:')) {
return { kind: 'url', state: applyMultiToggle(state, 'advisor', suggestionId.slice(8)) }
}
if (suggestionId.startsWith('trend:')) {
return { kind: 'url', state: applyMultiToggle(state, 'trend', suggestionId.slice(6)) }
}
if (suggestionId.startsWith('class:')) {
return { kind: 'url', state: applyMultiToggle(state, 'class', suggestionId.slice(6)) }
}
if (suggestionId.startsWith('level:')) {
return { kind: 'url', state: applyMultiToggle(state, 'level', suggestionId.slice(6)) }
}
if (suggestionId.startsWith('coverage:')) {
const value = suggestionId.slice(9)
if (value !== 'com_assessor' && value !== 'sem_assessor') return { kind: 'url', state }
return { kind: 'url', state: toggleMunicipalityExclusiveFilterValue(state, 'coverage', value) }
}
if (suggestionId.startsWith('scenario:')) {
const scenario = suggestionId.slice(9)
if (!(VOTE_ESTIMATE_SCENARIOS as readonly string[]).includes(scenario)) {
return { kind: 'url', state }
}
return { kind: 'scenario', scenario: scenario as VoteEstimateScenario }
}
if (suggestionId.startsWith('sort:')) {
const raw = suggestionId.slice(5)
const [key, dir] = raw.split('|')
const option = municipalityListSortOptions.find(
(entry) => entry.key === key && entry.dir === dir,
)
if (!option) return { kind: 'url', state }
const next = withPageReset({ ...state, sort: option.key, dir: option.dir })
// Canonical default omits sort/dir from the URL — clear when choosing default.
if (isDefaultMunicipalitySort(next)) {
return { kind: 'url', state: withPageReset({ ...state, sort: undefined, dir: undefined }) }
}
return { kind: 'url', state: next }
}
return { kind: 'url', state }
}
export const removeMunicipalityOmniboxChip = ({
state,
chipId,
}: {
state: MunicipalityListState
chipId: string
}): MunicipalityOmniboxAction => {
if (chipId === 'q') {
return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
}
if (chipId === 'priority') {
return { kind: 'url', state: withPageReset({ ...state, priority: undefined }) }
}
if (chipId === 'scenario') {
return { kind: 'scenario', scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO }
}
if (chipId === 'sort') {
return { kind: 'url', state: withPageReset({ ...state, sort: undefined, dir: undefined }) }
}
const multiPrefixes: Array<{ prefix: string; param: MunicipalityMultiFilterParam }> = [
{ prefix: 'slug:', param: 'slug' },
{ prefix: 'region:', param: 'region' },
{ prefix: 'advisor:', param: 'advisor' },
{ prefix: 'trend:', param: 'trend' },
{ prefix: 'class:', param: 'class' },
{ prefix: 'level:', param: 'level' },
]
for (const { prefix, param } of multiPrefixes) {
if (!chipId.startsWith(prefix)) continue
const value = chipId.slice(prefix.length)
// Toggle removes when present.
return { kind: 'url', state: applyMultiToggle(state, param, value) }
}
if (chipId.startsWith('coverage:')) {
return { kind: 'url', state: withPageReset({ ...state, coverage: undefined }) }
}
return { kind: 'url', state }
}
export const clearMunicipalityOmnibox = (state: MunicipalityListState): MunicipalityOmniboxAction => ({
kind: 'clear',
state: clearMunicipalityListFilters(state),
scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO,
})

View file

@ -11,18 +11,15 @@ vi.mock('next/navigation', async (importActual) => ({
useRouter: () => ({ replace: routerState.replace }),
}))
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { MunicipalityFilters } from '@/components/campaign/municipality/MunicipalityFilters'
import { TerritoryFilters } from '@/components/campaign/municipality/TerritoryFilters'
import { CampaignListPendingBoundary } from '@/components/campaign/shared/CampaignListPending'
import { SEARCH_DEBOUNCE_MS } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
/**
* The three list filter shells share one navigation scaffold
* (`useCampaignListFilterNavigation`). These cases are the contract that
* survives the extraction: debounce window, no-op dedup, unmount cleanup, and
* carrying the uncommitted search into a filter/sort navigation.
* List filter shells that still use the debounced-search scaffold
* (`useCampaignListFilterNavigation`). Municípios left this pattern in B127
* (omnibox); the hook contract is pinned by dobradinhas + territórios.
*/
type FilterCase = {
name: string
@ -79,35 +76,9 @@ const filterCases: FilterCase[] = [
regionOptions: [],
}),
},
{
name: 'municípios',
searchLabel: 'Buscar município',
hrefWithQuery: '/campanha/municipios?q=silva',
bareHref: '/campanha/municipios',
sortValue: 'name|asc',
element: (currentQuery) =>
createElement(
MunicipalityEstimateScenarioProvider,
null,
createElement(MunicipalityFilters, {
state: { page: 1, ...(currentQuery ? { q: currentQuery } : {}) },
showStaffFilters: true,
regionFilterOptions: [],
advisorFilterOptions: [],
}),
),
elementWithSort: () =>
createElement(
MunicipalityEstimateScenarioProvider,
null,
createElement(MunicipalityFilters, {
state: { page: 1, sort: 'name', dir: 'asc' },
showStaffFilters: true,
regionFilterOptions: [],
advisorFilterOptions: [],
}),
),
},
// Municípios (B127) left the debounced-search shell for the omnibox; the
// shared hook contract stays pinned by dobradinhas + territórios. Omnibox
// apply/remove lives in municipalityOmnibox.unit.spec.ts.
]
/**

View file

@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
import {
applyMunicipalityOmniboxSuggestion,
buildMunicipalityOmniboxChips,
buildMunicipalityOmniboxSuggestions,
clearMunicipalityOmnibox,
removeMunicipalityOmniboxChip,
} from '@/utilities/municipality/municipalityOmnibox'
import { parseMunicipalityListParams } from '@/utilities/municipality/municipalityListUrl'
describe('municipality omnibox (B127)', () => {
const base = parseMunicipalityListParams({})
it('builds chips from URL filters and non-default presentation', () => {
const state = parseMunicipalityListParams({
q: 'feira',
priority: 'alta',
region: 'Recôncavo',
sort: 'name',
dir: 'asc',
})
const chips = buildMunicipalityOmniboxChips({
state,
scenario: 'optimistic',
showStaffFilters: true,
advisorLabelsById: new Map(),
})
expect(chips.map((chip) => chip.id)).toEqual([
'q',
'priority',
'region:Recôncavo',
'scenario',
'sort',
])
expect(chips.find((chip) => chip.id === 'q')?.label).toBe('Busca: feira')
})
it('hides default scenario and default sort chips', () => {
const chips = buildMunicipalityOmniboxChips({
state: base,
scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO,
showStaffFilters: true,
advisorLabelsById: new Map(),
})
expect(chips).toEqual([])
})
it('suggests Busca and matching dimensions from typed text', () => {
const suggestions = buildMunicipalityOmniboxSuggestions({
query: 'pri',
showStaffFilters: true,
regionFilterOptions: [],
advisorFilterOptions: [],
slugFilterOptions: [{ value: 'salvador', label: 'Salvador' }],
})
expect(suggestions.some((entry) => entry.id === 'q:pri')).toBe(true)
expect(suggestions.some((entry) => entry.id === 'priority:alta')).toBe(true)
})
it('suggests sort options when typing ordenar', () => {
const suggestions = buildMunicipalityOmniboxSuggestions({
query: 'ordenar',
showStaffFilters: true,
regionFilterOptions: [],
advisorFilterOptions: [],
slugFilterOptions: [],
})
expect(suggestions.some((entry) => entry.group === 'Ordenação')).toBe(true)
})
it('applies text search and toggles inclusive filters', () => {
const search = applyMunicipalityOmniboxSuggestion({
state: base,
suggestionId: 'q:Salvador',
})
expect(search).toEqual({
kind: 'url',
state: expect.objectContaining({ q: 'Salvador', page: 1 }),
})
const withRegion = applyMunicipalityOmniboxSuggestion({
state: base,
suggestionId: 'region:Recôncavo',
})
expect(withRegion.kind).toBe('url')
if (withRegion.kind === 'url') {
expect(withRegion.state.regions).toEqual(['Recôncavo'])
}
})
it('applies scenario as presentation action and clears to central', () => {
const applied = applyMunicipalityOmniboxSuggestion({
state: base,
suggestionId: 'scenario:optimistic',
})
expect(applied).toEqual({ kind: 'scenario', scenario: 'optimistic' })
const removed = removeMunicipalityOmniboxChip({
state: base,
chipId: 'scenario',
})
expect(removed).toEqual({ kind: 'scenario', scenario: 'central' })
})
it('removes a single multi-value chip without clearing siblings', () => {
const state = parseMunicipalityListParams({
region: ['Recôncavo', 'Portal do Sertão'],
})
const next = removeMunicipalityOmniboxChip({ state, chipId: 'region:Recôncavo' })
expect(next.kind).toBe('url')
if (next.kind === 'url') {
expect(next.state.regions).toEqual(['Portal do Sertão'])
}
})
it('clear drops filters and resets scenario to central, keeping sort', () => {
const state = parseMunicipalityListParams({
q: 'x',
priority: 'alta',
sort: 'name',
dir: 'asc',
})
expect(clearMunicipalityOmnibox(state)).toEqual({
kind: 'clear',
state: { page: 1, sort: 'name', dir: 'asc' },
scenario: 'central',
})
})
})