fix(campanha): B127 omnibox navigate + keyboard a11y from simplify

Use hook.navigate so draft search cannot clobber chips; replace cmdk with
ARIA combobox keyboard contract; memoize suggestion seeds per facet snapshot.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 19:12:35 +00:00
parent e687be2ad3
commit 3b2be37972
No known key found for this signature in database
7 changed files with 394 additions and 271 deletions

View file

@ -29,9 +29,9 @@ flowchart LR
**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 |
| | 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”.
@ -90,9 +90,15 @@ Não aplica (só UI do recorte).
- 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).
- 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).

View file

@ -12,14 +12,16 @@ import {
municipalityFilterOptionsForSlugs,
type MunicipalityFilterOption,
} from '@/utilities/municipality/municipalityListFilters'
import type { MunicipalityListState } from '@/utilities/municipality/municipalityListUrl'
import {
applyMunicipalityOmniboxSuggestion,
buildMunicipalityOmniboxChips,
buildMunicipalityOmniboxSuggestions,
buildMunicipalityOmniboxSuggestionSeeds,
clearMunicipalityOmnibox,
filterMunicipalityOmniboxSuggestions,
removeMunicipalityOmniboxChip,
type MunicipalityOmniboxAction,
} from '@/utilities/municipality/municipalityOmnibox'
import type { MunicipalityListState } from '@/utilities/municipality/municipalityListUrl'
type MunicipalityFiltersProps = {
state: MunicipalityListState
@ -39,7 +41,7 @@ export const MunicipalityFilters = ({
advisorFilterOptions,
slugFilterValues = [],
}: MunicipalityFiltersProps) => {
const { navigateWithSearch, isPending } = useCampaignListFilterNavigation({
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: buildMunicipalityFilterHref,
})
@ -62,22 +64,34 @@ export const MunicipalityFilters = ({
return map
}, [advisorFilterOptions])
const chips = buildMunicipalityOmniboxChips({
state,
scenario,
showStaffFilters,
advisorLabelsById,
})
const chips = useMemo(
() =>
buildMunicipalityOmniboxChips({
state,
scenario,
showStaffFilters,
advisorLabelsById,
}),
[state, scenario, showStaffFilters, advisorLabelsById],
)
const suggestions = buildMunicipalityOmniboxSuggestions({
query,
showStaffFilters,
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
})
const suggestionSeeds = useMemo(
() =>
buildMunicipalityOmniboxSuggestionSeeds({
showStaffFilters,
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
}),
[showStaffFilters, regionFilterOptions, advisorFilterOptions, slugFilterOptions],
)
const runAction = (action: ReturnType<typeof applyMunicipalityOmniboxSuggestion>) => {
const suggestions = useMemo(
() => filterMunicipalityOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: MunicipalityOmniboxAction) => {
if (action.kind === 'scenario') {
setScenario?.(action.scenario)
return
@ -85,10 +99,10 @@ export const MunicipalityFilters = ({
if (action.kind === 'clear') {
setScenario?.(action.scenario)
setQuery('')
navigateWithSearch(action.state)
navigate(action.state)
return
}
navigateWithSearch(action.state)
navigate(action.state)
}
return (
@ -110,6 +124,9 @@ export const MunicipalityFilters = ({
onSelectSuggestion={(suggestionId) => {
runAction(applyMunicipalityOmniboxSuggestion({ state, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(applyMunicipalityOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
}}
onRemoveChip={(chipId) => {
runAction(removeMunicipalityOmniboxChip({ state, chipId }))
}}

View file

@ -1,23 +1,10 @@
'use client'
import { XIcon } from 'lucide-react'
import {
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from '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 { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
import type {
CampaignListOmniboxChip,
CampaignListOmniboxSuggestion,
@ -34,6 +21,8 @@ export type CampaignListOmniboxProps = {
onQueryChange: (query: string) => 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). */
@ -44,6 +33,10 @@ export type CampaignListOmniboxProps = {
* 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,
@ -55,6 +48,7 @@ export const CampaignListOmnibox = ({
onQueryChange,
onSelectSuggestion,
onRemoveChip,
onCommitQuery,
onClearAll,
isPending = false,
trailing,
@ -62,25 +56,52 @@ export const CampaignListOmnibox = ({
const reactId = useId()
const id = idProp ?? `campaign-list-omnibox-${reactId}`
const listboxId = `${id}-suggestions`
const optionId = (index: number) => `${id}-option-${index}`
const inputRef = useRef<HTMLInputElement>(null)
const [open, setOpen] = useState(false)
const [activeChipId, setActiveChipId] = useState<string | null>(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<HTMLInputElement>) => {
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()
@ -103,25 +124,34 @@ export const CampaignListOmnibox = ({
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)
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<string, CampaignListOmniboxSuggestion[]>()
for (const suggestion of suggestions) {
const map = new Map<string, { suggestion: CampaignListOmniboxSuggestion; index: number }[]>()
suggestions.forEach((suggestion, index) => {
const bucket = map.get(suggestion.group) ?? []
bucket.push(suggestion)
bucket.push({ suggestion, index })
map.set(suggestion.group, bucket)
}
})
return [...map.entries()]
})()
const activeChipLabel = activeChipId
? chips.find((chip) => chip.id === activeChipId)?.label
: null
return (
<div
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70 md:flex-row md:items-start"
@ -145,7 +175,6 @@ export const CampaignListOmnibox = ({
'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()
}}
>
@ -169,7 +198,7 @@ export const CampaignListOmnibox = ({
inputRef.current?.focus()
}}
>
<XIcon className="size-3.5" />
<XIcon className="size-3.5" aria-hidden />
</button>
</span>
))}
@ -178,8 +207,10 @@ export const CampaignListOmnibox = ({
id={id}
role="combobox"
aria-expanded={showSuggestions}
aria-controls={listboxId}
aria-controls={showSuggestions ? listboxId : undefined}
aria-activedescendant={activeOptionId}
aria-autocomplete="list"
aria-haspopup="listbox"
autoComplete="off"
spellCheck={false}
placeholder={hasChips ? '' : placeholder}
@ -196,33 +227,55 @@ export const CampaignListOmnibox = ({
</div>
</PopoverAnchor>
<PopoverContent
id={listboxId}
role="listbox"
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[min(100%,24rem)] p-0"
className="w-[var(--radix-popover-trigger-width)] min-w-[min(100%,24rem)] p-1"
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
<div
id={listboxId}
role="listbox"
aria-label={label}
className="max-h-72 overflow-y-auto"
>
{groups.map(([group, items]) => (
<div key={group} role="group" aria-label={group} className="py-1">
<p className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{group}</p>
{items.map(({ suggestion, index }) => {
const active = index === safeActiveIndex
return (
<button
key={suggestion.id}
value={suggestion.id}
onSelect={() => selectSuggestion(suggestion.id)}
className="min-h-11"
type="button"
id={optionId(index)}
role="option"
aria-selected={active}
className={cn(
'flex min-h-11 w-full items-center rounded-sm px-2 py-1.5 text-left text-sm outline-hidden',
active ? 'bg-muted text-foreground' : 'text-foreground hover:bg-muted/70',
)}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectSuggestion(suggestion.id)}
>
{suggestion.label}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</button>
)
})}
</div>
))}
</div>
</PopoverContent>
</Popover>
{activeChipLabel ? (
<p className="sr-only" aria-live="polite">
{activeChipLabel} selecionado. Backspace remove.
</p>
) : null}
{isPending ? (
<p className="sr-only" aria-live="polite">
Atualizando resultados…
</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 md:pt-7">

View file

@ -132,6 +132,11 @@ export const useCampaignListFilterNavigation = <State extends { q?: string }>({
*/
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,
}

View file

@ -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,

View file

@ -4,7 +4,6 @@
*/
import {
omniboxGroupMatches,
omniboxQueryMatches,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
@ -15,9 +14,14 @@ import {
voteEstimateScenarioLabels,
type VoteEstimateScenario,
} from '@/lib/voteEstimate'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import {
municipalityListCoverageLabels,
municipalityPriorityLabels,
} from '@/utilities/municipality/municipalityLabels'
import {
clearMunicipalityListFilters,
municipalityFilterDefinitions,
getMunicipalityFilterDefinition,
toggleMunicipalityExclusiveFilterValue,
toggleMunicipalityMultiFilterValue,
toggleMunicipalityPriorityFilter,
@ -25,18 +29,12 @@ import {
type MunicipalityMultiFilterParam,
} from '@/utilities/municipality/municipalityListFilters'
import {
municipalityListCoverageLabels,
municipalityPriorityLabels,
} from '@/utilities/municipality/municipalityLabels'
import {
defaultMunicipalityListSortDir,
isDefaultMunicipalityListSort,
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 =
@ -44,13 +42,43 @@ export type MunicipalityOmniboxAction =
| { 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)
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,
@ -97,29 +125,23 @@ export const buildMunicipalityOmniboxChips = ({
}
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),
label: chipLabel('Tendência', labelForFilterValue('trend', 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),
label: chipLabel('Classe', labelForFilterValue('class', 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),
label: chipLabel('Nível', labelForFilterValue('level', level)),
})
}
@ -131,7 +153,7 @@ export const buildMunicipalityOmniboxChips = ({
}
}
if (!isDefaultMunicipalitySort(state)) {
if (!isDefaultMunicipalityListSort(state)) {
const { sort, dir } = resolveMunicipalityListSort(state)
const option = municipalityListSortOptions.find(
(entry) => entry.key === sort && entry.dir === dir,
@ -145,39 +167,172 @@ export const buildMunicipalityOmniboxChips = ({
return chips
}
type SuggestionSeed = CampaignListOmniboxSuggestion & {
/** When true, show even with an empty query (dimension shortcuts). */
emptyQueryVisible?: boolean
/** 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
}
const pushMatching = (
out: CampaignListOmniboxSuggestion[],
export const filterMunicipalityOmniboxSuggestions = (
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)
): 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 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)
const groupCounts = new Map<string, number>()
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)
}
for (const bucket of byGroup.values()) out.push(...bucket)
return suggestions
}
/** Convenience for one-shot callers (tests); UI memoizes seeds + filters. */
export const buildMunicipalityOmniboxSuggestions = ({
query,
showStaffFilters,
@ -190,130 +345,16 @@ export const buildMunicipalityOmniboxSuggestions = ({
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)
}): CampaignListOmniboxSuggestion[] =>
filterMunicipalityOmniboxSuggestions(
buildMunicipalityOmniboxSuggestionSeeds({
showStaffFilters,
regionFilterOptions,
advisorFilterOptions,
slugFilterOptions,
}),
query,
)
export const applyMunicipalityOmniboxSuggestion = ({
state,
@ -331,23 +372,12 @@ export const applyMunicipalityOmniboxSuggestion = ({
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)) }
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:')) {
@ -372,8 +402,7 @@ export const applyMunicipalityOmniboxSuggestion = ({
)
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)) {
if (isDefaultMunicipalityListSort(next)) {
return { kind: 'url', state: withPageReset({ ...state, sort: undefined, dir: undefined }) }
}
return { kind: 'url', state: next }
@ -402,19 +431,12 @@ export const removeMunicipalityOmniboxChip = ({
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) {
for (const { prefix, param } of MULTI_PREFIXES) {
if (!chipId.startsWith(prefix)) continue
const value = chipId.slice(prefix.length)
// Toggle removes when present.
return { kind: 'url', state: applyMultiToggle(state, param, value) }
return {
kind: 'url',
state: toggleMunicipalityMultiFilterValue(state, param, chipId.slice(prefix.length)),
}
}
if (chipId.startsWith('coverage:')) {
@ -424,7 +446,9 @@ export const removeMunicipalityOmniboxChip = ({
return { kind: 'url', state }
}
export const clearMunicipalityOmnibox = (state: MunicipalityListState): MunicipalityOmniboxAction => ({
export const clearMunicipalityOmnibox = (
state: MunicipalityListState,
): MunicipalityOmniboxAction => ({
kind: 'clear',
state: clearMunicipalityListFilters(state),
scenario: DEFAULT_VOTE_ESTIMATE_SCENARIO,

View file

@ -128,4 +128,17 @@ describe('municipality omnibox (B127)', () => {
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 }),
})
})
})