diff --git a/docs/plans/barra-filtros-omnibox-listas-impl.md b/docs/plans/barra-filtros-omnibox-listas-impl.md
new file mode 100644
index 00000000..1541434e
--- /dev/null
+++ b/docs/plans/barra-filtros-omnibox-listas-impl.md
@@ -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 `
` “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).
diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
index dd6d067f..5b0c0e99 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
@@ -99,6 +99,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
showStaffFilters={isStaffView}
regionFilterOptions={columnFilterOptions.region}
advisorFilterOptions={columnFilterOptions.advisor}
+ slugFilterValues={columnFilterOptions.name}
/>
)
diff --git a/src/components/campaign/municipality/MunicipalityFilters.tsx b/src/components/campaign/municipality/MunicipalityFilters.tsx
index 4e9fd10a..ae3ac932 100644
--- a/src/components/campaign/municipality/MunicipalityFilters.tsx
+++ b/src/components/campaign/municipality/MunicipalityFilters.tsx
@@ -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 (
-
- )
-}
+ 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()
+ 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) => {
+ if (action.kind === 'scenario') {
+ setScenario?.(action.scenario)
+ return
+ }
+ if (action.kind === 'clear') {
+ setScenario?.(action.scenario)
+ setQuery('')
+ navigateWithSearch(action.state)
+ return
+ }
+ navigateWithSearch(action.state)
+ }
+
return (
)
}
diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx
index f120c9d3..0f761667 100644
--- a/src/components/campaign/municipality/MunicipalityList.tsx
+++ b/src/components/campaign/municipality/MunicipalityList.tsx
@@ -427,10 +427,6 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
return (
<>
-
- {sortSummary}
-
-
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(null)
+ const [open, setOpen] = useState(false)
+ const [activeChipId, setActiveChipId] = useState(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) => {
+ 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()
+ for (const suggestion of suggestions) {
+ const bucket = map.get(suggestion.group) ?? []
+ bucket.push(suggestion)
+ map.set(suggestion.group, bucket)
+ }
+ return [...map.entries()]
+ })()
+
+ return (
+
+
+
+
{
+ if (!next) setOpen(false)
+ }}
+ >
+
+ {
+ // Keep focus on the input when clicking chips/padding.
+ if (event.target === event.currentTarget) inputRef.current?.focus()
+ }}
+ >
+ {chips.map((chip) => (
+
+ {chip.label}
+
+
+ ))}
+ {
+ 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"
+ />
+
+
+ event.preventDefault()}
+ onCloseAutoFocus={(event) => event.preventDefault()}
+ >
+
+
+ {groups.map(([group, items]) => (
+
+ {items.map((suggestion) => (
+ selectSuggestion(suggestion.id)}
+ className="min-h-11"
+ >
+ {suggestion.label}
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+
+ {trailing}
+ {onClearAll && hasChips ? (
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/lib/campaignListOmnibox.ts b/src/lib/campaignListOmnibox.ts
new file mode 100644
index 00000000..5bee0ff0
--- /dev/null
+++ b/src/lib/campaignListOmnibox.ts
@@ -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)
diff --git a/src/utilities/municipality/municipalityOmnibox.ts b/src/utilities/municipality/municipalityOmnibox.ts
new file mode 100644
index 00000000..1b3097ff
--- /dev/null
+++ b/src/utilities/municipality/municipalityOmnibox.ts
@@ -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
+}): 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()
+ 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,
+})
diff --git a/tests/unit/campaignListFilterNavigation.unit.spec.ts b/tests/unit/campaignListFilterNavigation.unit.spec.ts
index 944bc3ef..42678634 100644
--- a/tests/unit/campaignListFilterNavigation.unit.spec.ts
+++ b/tests/unit/campaignListFilterNavigation.unit.spec.ts
@@ -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.
]
/**
diff --git a/tests/unit/municipalityOmnibox.unit.spec.ts b/tests/unit/municipalityOmnibox.unit.spec.ts
new file mode 100644
index 00000000..80f959c4
--- /dev/null
+++ b/tests/unit/municipalityOmnibox.unit.spec.ts
@@ -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',
+ })
+ })
+})