diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index 8380f331..13abb166 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
+**Recently resolved (2026-08-02):** **B127** (Issue #264) — barra omnibox de filtros no piloto Municípios + chassis `CampaignListOmnibox` (chips + sugestões; URL como fonte de verdade; cenário/sort como chips de apresentação). Planos: [`barra-filtros-omnibox-listas.md`](docs/plans/barra-filtros-omnibox-listas.md), impl [`barra-filtros-omnibox-listas-impl.md`](docs/plans/barra-filtros-omnibox-listas-impl.md). Adoção nas demais listas = B128.
+
**Recently resolved (2026-08-02):** **Skills plan/work reescritas** — `plan-issue` agora só gera planos de **intenção** (persona/fluxo/aceite + direção suave no código; sem decisões duras de engenharia). Execução: `agent-work-issue` (pool — Plan mode → `*-impl.md` → executa → `/simplify` → `capture-review-debts` autônomo → PR Ready + auto-merge) e `work-issue` (humano — claim + **pausa** após o impl plan). Prompt do pool aponta para `agent-work-issue`.
**Recently resolved (2026-08-02):** **WhatsApp interno D3–D5 descartado** — Issues #30–#32 `wontfix` (bridge Baileys/Evolution/WAHA / linked device): risco de ban do número pessoal do staff. Canal no produto = só `wa.me`. Planos `whatsapp-interno-campanha.md`, `whatsapp-canal-fundacao.md`, `whatsapp-envio-liderancas.md`, `whatsapp-sugestao-atualizacoes.md` removidos; refs em `notifications.md` / convite·email listas / fluxos-ação / regra `nucleos-eleitorais` atualizadas.
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..a29133c6
--- /dev/null
+++ b/docs/plans/barra-filtros-omnibox-listas-impl.md
@@ -0,0 +1,114 @@
+# 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 + seeds memoizados no piloto).
+- Redesign do header filter / mapa.
+
+### Débitos deferidos (/simplify 2026-08-02)
+
+- Extrair navigate-only helper do hook de debounce quando 2º consumidor omnibox (B128).
+- Inline `omniboxGroupMatches` se a API não crescer no B128.
+- Anunciar contagem de sugestões (live region) quando polish a11y for pedido.
+
+## 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/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs
index dd061551..27af0298 100644
--- a/scripts/lib/e2e-affected-manifest.mjs
+++ b/scripts/lib/e2e-affected-manifest.mjs
@@ -29,6 +29,8 @@ export const E2E_AFFECTED_MANIFEST = [
`${CAMPAIGN_APP}/municipios`,
'src/components/campaign/municipality',
'src/components/campaign/map',
+ 'src/components/campaign/shared/CampaignListOmnibox',
+ 'src/lib/campaignListOmnibox',
'src/utilities/municipality',
],
specs: [
@@ -107,6 +109,7 @@ export const E2E_AFFECTED_MANIFEST = [
'src/components/campaign/auth',
`${CAMPAIGN_APP}/acoes`,
'src/components/campaign/shared/WizardMunicipality',
+ 'src/components/campaign/shared/useCampaignListFilterNavigation',
'src/components/campaign/shared/useNearestMunicipalitySlug',
'src/lib/wizardMunicipalitySuggestMerge',
`${CAMPAIGN_APP}/apoiadores`,
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/MunicipalityEstimateScenarioContext.tsx b/src/components/campaign/municipality/MunicipalityEstimateScenarioContext.tsx
index 9dd3340b..a52df2cc 100644
--- a/src/components/campaign/municipality/MunicipalityEstimateScenarioContext.tsx
+++ b/src/components/campaign/municipality/MunicipalityEstimateScenarioContext.tsx
@@ -23,16 +23,6 @@ export const MunicipalityEstimateScenarioProvider = ({ children }: { children: R
)
}
-export const useMunicipalityEstimateScenario = (): MunicipalityEstimateScenarioContextValue => {
- const value = useContext(MunicipalityEstimateScenarioContext)
- if (!value) {
- throw new Error(
- 'useMunicipalityEstimateScenario must be used within MunicipalityEstimateScenarioProvider',
- )
- }
- return value
-}
-
export const useMunicipalityEstimateScenarioOptional =
(): MunicipalityEstimateScenarioContextValue | null =>
useContext(MunicipalityEstimateScenarioContext)
diff --git a/src/components/campaign/municipality/MunicipalityFilters.tsx b/src/components/campaign/municipality/MunicipalityFilters.tsx
index 4e9fd10a..f4e6c888 100644
--- a/src/components/campaign/municipality/MunicipalityFilters.tsx
+++ b/src/components/campaign/municipality/MunicipalityFilters.tsx
@@ -1,54 +1,27 @@
'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 type { MunicipalityListState } from '@/utilities/municipality/municipalityListUrl'
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,
+ buildMunicipalityOmniboxSuggestionSeeds,
+ clearMunicipalityOmnibox,
+ filterMunicipalityOmniboxSuggestions,
+ removeMunicipalityOmniboxChip,
+ type MunicipalityOmniboxAction,
+} from '@/utilities/municipality/municipalityOmnibox'
type MunicipalityFiltersProps = {
state: MunicipalityListState
@@ -57,6 +30,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 +39,102 @@ 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 { navigate, 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 = useMemo(
+ () =>
+ buildMunicipalityOmniboxChips({
+ state,
+ scenario,
+ showStaffFilters,
+ advisorLabelsById,
+ }),
+ [state, scenario, showStaffFilters, advisorLabelsById],
+ )
+
+ const suggestionSeeds = useMemo(
+ () =>
+ buildMunicipalityOmniboxSuggestionSeeds({
+ showStaffFilters,
+ regionFilterOptions,
+ advisorFilterOptions,
+ slugFilterOptions,
+ }),
+ [showStaffFilters, regionFilterOptions, advisorFilterOptions, slugFilterOptions],
+ )
+
+ const suggestions = useMemo(
+ () => filterMunicipalityOmniboxSuggestions(suggestionSeeds, query),
+ [suggestionSeeds, query],
+ )
+
+ const runAction = (action: MunicipalityOmniboxAction) => {
+ if (action.kind === 'scenario') {
+ setScenario?.(action.scenario)
+ return
+ }
+ if (action.kind === 'clear') {
+ setScenario?.(action.scenario)
+ setQuery('')
+ navigate(action.state)
+ return
+ }
+ navigate(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
+ /** Free-text commit (Enter with no arrow selection / default). Domain owns id scheme. */
+ onCommitQuery?: (query: 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.
+ *
+ * Keyboard contract mirrors RelationChipCell (ARIA combobox by hand): arrows
+ * move the active option, Enter picks it, Escape closes. cmdk is not used —
+ * its Root expects an inner CommandInput and would leave this field mouse-only.
+ */
+export const CampaignListOmnibox = ({
+ id: idProp,
+ label,
+ placeholder,
+ chips,
+ suggestions,
+ query,
+ onQueryChange,
+ onSelectSuggestion,
+ onRemoveChip,
+ onCommitQuery,
+ onClearAll,
+ isPending = false,
+ trailing,
+}: CampaignListOmniboxProps) => {
+ const reactId = useId()
+ const id = idProp ?? `campaign-list-omnibox-${reactId}`
+ const listboxId = `${id}-suggestions`
+ const optionId = (index: number) => `${id}-option-${index}`
+ const inputRef = useRef(null)
+ const [open, setOpen] = useState(false)
+ const [activeChipId, setActiveChipId] = useState(null)
+ const [activeIndex, setActiveIndex] = useState(0)
+
+ const hasChips = chips.length > 0
+ const showSuggestions = open && suggestions.length > 0
+ const safeActiveIndex =
+ suggestions.length === 0 ? 0 : Math.min(activeIndex, suggestions.length - 1)
+ const activeOptionId = showSuggestions ? optionId(safeActiveIndex) : undefined
+
+ useEffect(() => {
+ if (!open) setActiveChipId(null)
+ }, [open])
+
+ useEffect(() => {
+ setActiveIndex(0)
+ }, [query])
+
+ const selectSuggestion = (suggestionId: string) => {
+ onSelectSuggestion(suggestionId)
+ onQueryChange('')
+ setOpen(false)
+ setActiveIndex(0)
+ inputRef.current?.focus()
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ if (suggestions.length === 0) return
+ event.preventDefault()
+ setOpen(true)
+ const delta = event.key === 'ArrowDown' ? 1 : -1
+ setActiveIndex((current) => (current + delta + suggestions.length) % suggestions.length)
+ return
+ }
+
+ if (event.key === 'Home' || event.key === 'End') {
+ if (suggestions.length === 0) return
+ event.preventDefault()
+ setOpen(true)
+ setActiveIndex(event.key === 'Home' ? 0 : suggestions.length - 1)
+ return
+ }
+
+ 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 (showSuggestions && suggestions[safeActiveIndex]) {
+ event.preventDefault()
+ selectSuggestion(suggestions[safeActiveIndex]!.id)
+ return
+ }
+ if (trimmed && onCommitQuery) {
+ event.preventDefault()
+ onCommitQuery(trimmed)
+ onQueryChange('')
+ setOpen(false)
+ }
+ }
+ }
+
+ const groups = (() => {
+ const map = new Map()
+ suggestions.forEach((suggestion, index) => {
+ const bucket = map.get(suggestion.group) ?? []
+ bucket.push({ suggestion, index })
+ map.set(suggestion.group, bucket)
+ })
+ return [...map.entries()]
+ })()
+
+ const activeChipLabel = activeChipId
+ ? chips.find((chip) => chip.id === activeChipId)?.label
+ : null
+
+ return (
+
+
+
+ {label}
+
+
{
+ if (!next) setOpen(false)
+ }}
+ >
+
+ {
+ if (event.target === event.currentTarget) inputRef.current?.focus()
+ }}
+ >
+ {chips.map((chip) => (
+
+ {chip.label}
+ {
+ onRemoveChip(chip.id)
+ setActiveChipId(null)
+ inputRef.current?.focus()
+ }}
+ >
+
+
+
+ ))}
+ {
+ 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]) => (
+
+
{group}
+ {items.map(({ suggestion, index }) => {
+ const active = index === safeActiveIndex
+ return (
+
setActiveIndex(index)}
+ onClick={() => selectSuggestion(suggestion.id)}
+ >
+ {suggestion.label}
+
+ )
+ })}
+
+ ))}
+
+
+
+ {activeChipLabel ? (
+
+ {activeChipLabel} selecionado. Backspace remove.
+
+ ) : null}
+ {isPending ? (
+
+ Atualizando resultados…
+
+ ) : null}
+
+
+
+ {trailing}
+ {onClearAll && hasChips ? (
+
+ Limpar
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/campaign/shared/useCampaignListFilterNavigation.ts b/src/components/campaign/shared/useCampaignListFilterNavigation.ts
index 5eaa520a..54347862 100644
--- a/src/components/campaign/shared/useCampaignListFilterNavigation.ts
+++ b/src/components/campaign/shared/useCampaignListFilterNavigation.ts
@@ -13,9 +13,9 @@ import { normalizedText } from '@/utilities/campaignListUrl'
export const SEARCH_DEBOUNCE_MS = 1000
/**
- * Navigation scaffold shared by the list filter shells (municípios,
- * territórios, dobradinhas): debounced search, no-op deduplication and the
- * pending transition the results region dims with.
+ * Navigation scaffold shared by the list filter shells that still use a
+ * debounced search box (territórios, dobradinhas). Municípios moved to the
+ * omnibox in B127 but still calls `navigateWithSearch` from the same hook.
*
* The hook never decides what is canonical — `toHref` comes from the domain's
* own URL module (`buildXListHref`, which already canonicalizes), so the three
@@ -132,6 +132,11 @@ export const useCampaignListFilterNavigation = ({
*/
draftQ: normalizedText(search) || state.q,
isPending,
+ /**
+ * Navigate with the caller's state as-is (no draft-search merge). Used by
+ * shells that own committed `q` themselves (B127 omnibox).
+ */
+ navigate: navigateTo,
navigateWithSearch,
clearSearchAndNavigate,
}
diff --git a/src/components/campaign/shell/CampaignPageChromeText.tsx b/src/components/campaign/shell/CampaignPageChromeText.tsx
index 24355d09..6f0c85ea 100644
--- a/src/components/campaign/shell/CampaignPageChromeText.tsx
+++ b/src/components/campaign/shell/CampaignPageChromeText.tsx
@@ -16,7 +16,7 @@ export const CampaignPageChromeText = ({
data-slot="campaign-page-chrome"
className={cn('min-w-0 truncate text-sm font-medium text-foreground', className)}
>
- {chrome.title}
+ {chrome.title}
{chrome.subtitle ? (
{chrome.subtitle}
) : null}
@@ -26,7 +26,9 @@ export const CampaignPageChromeText = ({
return (
- {chrome.title}
+
+ {chrome.title}
+
{chrome.subtitle ? (
{chrome.subtitle}
) : null}
diff --git a/src/components/campaign/votePledge/VoteEstimateScenarioField.tsx b/src/components/campaign/votePledge/VoteEstimateScenarioField.tsx
index 868693b2..654b4c39 100644
--- a/src/components/campaign/votePledge/VoteEstimateScenarioField.tsx
+++ b/src/components/campaign/votePledge/VoteEstimateScenarioField.tsx
@@ -10,9 +10,6 @@ import {
type VoteEstimateScenario,
} from '@/lib/voteEstimate'
-export const VOTE_ESTIMATE_SCENARIO_FILTERS_HINT =
- 'Troca o total da visão geral e os votos na lista. No mapa do Início, a cor só muda com Ano 2026.'
-
export const VOTE_ESTIMATE_SCENARIO_MAP_HINT = 'Pinta o mapa e os totais da lista abaixo.'
type VoteEstimateScenarioFieldProps = {
diff --git a/src/lib/campaignListOmnibox.ts b/src/lib/campaignListOmnibox.ts
new file mode 100644
index 00000000..6f3cd8e6
--- /dev/null
+++ b/src/lib/campaignListOmnibox.ts
@@ -0,0 +1,33 @@
+/**
+ * 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[]
+}
+
+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/municipalityListFilters.ts b/src/utilities/municipality/municipalityListFilters.ts
index 917c5ae7..13d34f68 100644
--- a/src/utilities/municipality/municipalityListFilters.ts
+++ b/src/utilities/municipality/municipalityListFilters.ts
@@ -62,7 +62,7 @@ export type MunicipalityFilterDefinition = {
options?: MunicipalityFilterOption[]
}
-export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
+const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
{
param: 'name',
label: 'Município',
diff --git a/src/utilities/municipality/municipalityListUrl.ts b/src/utilities/municipality/municipalityListUrl.ts
index 1760ec50..804dd12d 100644
--- a/src/utilities/municipality/municipalityListUrl.ts
+++ b/src/utilities/municipality/municipalityListUrl.ts
@@ -201,6 +201,11 @@ export const resolveMunicipalityListSort = (
return { sort, dir }
}
+export const isDefaultMunicipalityListSort = (state: MunicipalityListState): boolean => {
+ const { sort, dir } = resolveMunicipalityListSort(state)
+ return sort === DEFAULT_MUNICIPALITY_LIST_SORT_KEY && dir === defaultMunicipalityListSortDir(sort)
+}
+
export const formatMunicipalityListSortSummary = (
sort: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
diff --git a/src/utilities/municipality/municipalityOmnibox.ts b/src/utilities/municipality/municipalityOmnibox.ts
new file mode 100644
index 00000000..2eb7bf26
--- /dev/null
+++ b/src/utilities/municipality/municipalityOmnibox.ts
@@ -0,0 +1,455 @@
+/**
+ * Municipality list omnibox adapter (B127): chips, suggestions and apply/remove
+ * over the frozen URL contract + presentation scenario. Pure / client-safe.
+ */
+import {
+ omniboxGroupMatches,
+ 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 { normalizeSearchPhrase } from '@/lib/wordStartFilter'
+import {
+ municipalityListCoverageLabels,
+ municipalityPriorityLabels,
+} from '@/utilities/municipality/municipalityLabels'
+import {
+ clearMunicipalityListFilters,
+ getMunicipalityFilterDefinition,
+ toggleMunicipalityExclusiveFilterValue,
+ toggleMunicipalityMultiFilterValue,
+ toggleMunicipalityPriorityFilter,
+ type MunicipalityFilterOption,
+ type MunicipalityMultiFilterParam,
+} from '@/utilities/municipality/municipalityListFilters'
+import {
+ isDefaultMunicipalityListSort,
+ municipalityListSortOptions,
+ resolveMunicipalityListSort,
+ type MunicipalityListState,
+} from '@/utilities/municipality/municipalityListUrl'
+
+const SUGGESTION_CAP_PER_GROUP = 8
+
+export type MunicipalityOmniboxAction =
+ | { kind: 'url'; state: MunicipalityListState }
+ | { kind: 'scenario'; scenario: VoteEstimateScenario }
+ | { kind: 'clear'; state: MunicipalityListState; scenario: VoteEstimateScenario }
+
+type SuggestionSeed = CampaignListOmniboxSuggestion & {
+ /** When true, show even with an empty query (dimension shortcuts). */
+ emptyQueryVisible?: boolean
+ normalizedLabel: string
+ normalizedKeywords: readonly string[]
+}
+
+const MULTI_PREFIXES: 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' },
+]
+
+const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
+
+const labelForFilterValue = (param: 'trend' | 'class' | 'level', value: string): string =>
+ getMunicipalityFilterDefinition(param).options?.find((option) => option.value === value)?.label ??
+ value
+
+const withPageReset = (state: MunicipalityListState): MunicipalityListState => ({
+ ...state,
+ page: 1,
+})
+
+const seedOf = (
+ suggestion: CampaignListOmniboxSuggestion,
+ extras?: { emptyQueryVisible?: boolean },
+): SuggestionSeed => ({
+ ...suggestion,
+ ...extras,
+ normalizedLabel: normalizeSearchPhrase(suggestion.label),
+ normalizedKeywords: (suggestion.keywords ?? []).map((keyword) => normalizeSearchPhrase(keyword)),
+})
+
+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 ?? []) {
+ chips.push({
+ id: `trend:${trend}`,
+ label: chipLabel('Tendência', labelForFilterValue('trend', trend)),
+ })
+ }
+
+ for (const territorialClass of state.classes ?? []) {
+ chips.push({
+ id: `class:${territorialClass}`,
+ label: chipLabel('Classe', labelForFilterValue('class', territorialClass)),
+ })
+ }
+
+ for (const level of state.levels ?? []) {
+ chips.push({
+ id: `level:${level}`,
+ label: chipLabel('Nível', labelForFilterValue('level', level)),
+ })
+ }
+
+ if (scenario !== DEFAULT_VOTE_ESTIMATE_SCENARIO) {
+ chips.push({
+ id: 'scenario',
+ label: chipLabel('Cenário', voteEstimateScenarioLabels[scenario]),
+ })
+ }
+ }
+
+ if (!isDefaultMunicipalityListSort(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
+}
+
+/** Stable suggestion catalog for a facet snapshot — memoize across keystrokes. */
+export const buildMunicipalityOmniboxSuggestionSeeds = ({
+ showStaffFilters,
+ regionFilterOptions,
+ advisorFilterOptions,
+ slugFilterOptions,
+}: {
+ showStaffFilters: boolean
+ regionFilterOptions: readonly MunicipalityFilterOption[]
+ advisorFilterOptions: readonly MunicipalityFilterOption[]
+ slugFilterOptions: readonly MunicipalityFilterOption[]
+}): SuggestionSeed[] => {
+ const seeds: SuggestionSeed[] = []
+
+ if (showStaffFilters) {
+ seeds.push(
+ seedOf(
+ {
+ id: 'priority:alta',
+ group: 'Prioritária',
+ label: municipalityPriorityLabels.alta,
+ keywords: ['prioritaria', 'prioridade', 'alta'],
+ },
+ { emptyQueryVisible: true },
+ ),
+ )
+ }
+
+ for (const option of slugFilterOptions) {
+ seeds.push(
+ seedOf({
+ id: `slug:${option.value}`,
+ group: 'Município',
+ label: option.label,
+ keywords: ['municipio', option.value],
+ }),
+ )
+ }
+
+ for (const option of regionFilterOptions) {
+ seeds.push(
+ seedOf({
+ id: `region:${option.value}`,
+ group: 'Território',
+ label: option.label,
+ keywords: ['territorio', 'regiao'],
+ }),
+ )
+ }
+
+ if (showStaffFilters) {
+ for (const option of advisorFilterOptions) {
+ seeds.push(
+ seedOf({
+ id: `advisor:${option.value}`,
+ group: 'Assessor',
+ label: option.label,
+ keywords: ['assessor', 'assessoria'],
+ }),
+ )
+ }
+
+ for (const [value, label] of Object.entries(municipalityListCoverageLabels)) {
+ seeds.push(
+ seedOf(
+ {
+ id: `coverage:${value}`,
+ group: 'Assessoria',
+ label,
+ keywords: ['cobertura', 'assessor'],
+ },
+ { emptyQueryVisible: true },
+ ),
+ )
+ }
+
+ for (const param of ['trend', 'class', 'level'] as const) {
+ const definition = getMunicipalityFilterDefinition(param)
+ for (const option of definition.options ?? []) {
+ seeds.push(
+ seedOf({
+ id: `${param}:${option.value}`,
+ group: definition.label,
+ label: option.label,
+ keywords: [definition.label.toLowerCase()],
+ }),
+ )
+ }
+ }
+
+ for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
+ seeds.push(
+ seedOf(
+ {
+ id: `scenario:${scenario}`,
+ group: 'Cenário',
+ label: voteEstimateScenarioLabels[scenario],
+ keywords: ['cenario', 'estimativa', 'cenário'],
+ },
+ { emptyQueryVisible: true },
+ ),
+ )
+ }
+ }
+
+ for (const option of municipalityListSortOptions) {
+ seeds.push(
+ seedOf({
+ id: `sort:${option.key}|${option.dir}`,
+ group: 'Ordenação',
+ label: option.label,
+ keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
+ }),
+ )
+ }
+
+ return seeds
+}
+
+export const filterMunicipalityOmniboxSuggestions = (
+ seeds: readonly SuggestionSeed[],
+ query: string,
+): CampaignListOmniboxSuggestion[] => {
+ const suggestions: CampaignListOmniboxSuggestion[] = []
+ const trimmed = query.trim()
+ const normalizedNeedle = normalizeSearchPhrase(trimmed)
+
+ if (trimmed) {
+ suggestions.push({
+ id: `q:${trimmed}`,
+ group: 'Busca',
+ label: chipLabel('Busca', trimmed),
+ keywords: ['busca', 'pesquisar', 'texto'],
+ })
+ }
+
+ const groupCounts = new Map()
+
+ for (const seed of seeds) {
+ const count = groupCounts.get(seed.group) ?? 0
+ if (count >= SUGGESTION_CAP_PER_GROUP) continue
+
+ if (!normalizedNeedle) {
+ if (!seed.emptyQueryVisible) continue
+ } else {
+ const groupHit = omniboxGroupMatches(seed.group, trimmed)
+ const labelHit =
+ seed.normalizedLabel.includes(normalizedNeedle) ||
+ seed.normalizedKeywords.some((keyword) => keyword.includes(normalizedNeedle))
+ if (!groupHit && !labelHit) continue
+ }
+
+ const {
+ emptyQueryVisible: _visible,
+ normalizedLabel: _label,
+ normalizedKeywords: _kw,
+ ...suggestion
+ } = seed
+ suggestions.push(suggestion)
+ groupCounts.set(seed.group, count + 1)
+ }
+
+ return suggestions
+}
+
+/** Convenience for one-shot callers (tests); UI memoizes seeds + filters. */
+export const buildMunicipalityOmniboxSuggestions = ({
+ query,
+ showStaffFilters,
+ regionFilterOptions,
+ advisorFilterOptions,
+ slugFilterOptions,
+}: {
+ query: string
+ showStaffFilters: boolean
+ regionFilterOptions: readonly MunicipalityFilterOption[]
+ advisorFilterOptions: readonly MunicipalityFilterOption[]
+ slugFilterOptions: readonly MunicipalityFilterOption[]
+}): CampaignListOmniboxSuggestion[] =>
+ filterMunicipalityOmniboxSuggestions(
+ buildMunicipalityOmniboxSuggestionSeeds({
+ showStaffFilters,
+ regionFilterOptions,
+ advisorFilterOptions,
+ slugFilterOptions,
+ }),
+ query,
+ )
+
+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) }
+ }
+
+ for (const { prefix, param } of MULTI_PREFIXES) {
+ if (!suggestionId.startsWith(prefix)) continue
+ return {
+ kind: 'url',
+ state: toggleMunicipalityMultiFilterValue(state, param, suggestionId.slice(prefix.length)),
+ }
+ }
+
+ 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 })
+ if (isDefaultMunicipalityListSort(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 }) }
+ }
+
+ for (const { prefix, param } of MULTI_PREFIXES) {
+ if (!chipId.startsWith(prefix)) continue
+ return {
+ kind: 'url',
+ state: toggleMunicipalityMultiFilterValue(state, param, chipId.slice(prefix.length)),
+ }
+ }
+
+ 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/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts
index b41e4b9b..7082dd76 100644
--- a/tests/e2e/campaignMunicipalities.e2e.spec.ts
+++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts
@@ -40,12 +40,9 @@ test.describe('Municípios — jornadas por papel', () => {
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
- // E9 allocation queue: the list opens on the uncovered deficit and exposes
- // the freshness column the ordering is paired with. `exact` keeps this off
- // the table caption, which embeds the same summary plus column glossary.
- await expect(
- page.getByText('Ordenado por Cobertura (maior déficit primeiro)', { exact: true }),
- ).toBeVisible()
+ // B127: primary filter entry is the omnibox; default sort is no longer a
+ // free-standing "Ordenado por …" line (only a non-default sort chip).
+ await expect(page.getByLabel('Filtrar municípios')).toBeVisible()
await expect(
page.getByRole('columnheader', { name: /Ordenar por Frescor do sinal/ }),
).toBeVisible()
@@ -97,7 +94,7 @@ test.describe('Municípios — jornadas por papel', () => {
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
- const advisorsTrigger = page.getByRole('button', { name: 'Editar assessores' })
+ const advisorsTrigger = page.getByRole('button', { name: 'Editar assessores' }).first()
await advisorsTrigger.click()
const advisorsPopover = page.locator('[data-slot="popover-content"]')
@@ -127,7 +124,7 @@ test.describe('Municípios — jornadas por papel', () => {
// Persistence: reload and reopen to confirm the server, not just local
// state, now holds the assignment.
await page.reload()
- await page.getByRole('button', { name: 'Editar assessores' }).click()
+ await page.getByRole('button', { name: 'Editar assessores' }).first().click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${advisor.name}`,
diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts
index 8147f765..8d5b145e 100644
--- a/tests/e2e/fixtures/campaignE2EFixtures.ts
+++ b/tests/e2e/fixtures/campaignE2EFixtures.ts
@@ -565,7 +565,7 @@ export const campaignPageChrome = (page: Page, title: string) =>
page
.locator('[data-slot="campaign-page-chrome"]')
.filter({ visible: true })
- .getByText(title)
- .first()
+ .locator('[data-slot="campaign-page-chrome-title"]')
+ .getByText(title, { exact: true })
export { expect }
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..e84a73f6
--- /dev/null
+++ b/tests/unit/municipalityOmnibox.unit.spec.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest'
+
+import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
+import { parseMunicipalityListParams } from '@/utilities/municipality/municipalityListUrl'
+import {
+ applyMunicipalityOmniboxSuggestion,
+ buildMunicipalityOmniboxChips,
+ buildMunicipalityOmniboxSuggestions,
+ clearMunicipalityOmnibox,
+ removeMunicipalityOmniboxChip,
+} from '@/utilities/municipality/municipalityOmnibox'
+
+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',
+ })
+ })
+
+ it('apply search suggestion sets q without depending on draft search', () => {
+ // Callers must navigate with action.state as-is (hook.navigate), not
+ // navigateWithSearch which would re-inject the unused draft q.
+ const applied = applyMunicipalityOmniboxSuggestion({
+ state: parseMunicipalityListParams({ q: 'old' }),
+ suggestionId: 'q:novo',
+ })
+ expect(applied).toEqual({
+ kind: 'url',
+ state: expect.objectContaining({ q: 'novo', page: 1 }),
+ })
+ })
+})