diff --git a/docs/plans/barra-filtros-omnibox-listas-impl.md b/docs/plans/barra-filtros-omnibox-listas-impl.md index 1541434e..a29133c6 100644 --- a/docs/plans/barra-filtros-omnibox-listas-impl.md +++ b/docs/plans/barra-filtros-omnibox-listas-impl.md @@ -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). diff --git a/src/components/campaign/municipality/MunicipalityFilters.tsx b/src/components/campaign/municipality/MunicipalityFilters.tsx index ae3ac932..f4e6c888 100644 --- a/src/components/campaign/municipality/MunicipalityFilters.tsx +++ b/src/components/campaign/municipality/MunicipalityFilters.tsx @@ -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) => { + 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 })) }} diff --git a/src/components/campaign/shared/CampaignListOmnibox.tsx b/src/components/campaign/shared/CampaignListOmnibox.tsx index c3ece656..a54db1b8 100644 --- a/src/components/campaign/shared/CampaignListOmnibox.tsx +++ b/src/components/campaign/shared/CampaignListOmnibox.tsx @@ -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(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() @@ -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() - for (const suggestion of suggestions) { + const map = new Map() + 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 (
{ - // 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() }} > - + ))} @@ -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 = ({
event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} > - - - {groups.map(([group, items]) => ( - - {items.map((suggestion) => ( - + {groups.map(([group, items]) => ( +
+

{group}

+ {items.map(({ suggestion, index }) => { + const active = index === safeActiveIndex + return ( + + ) + })} +
+ ))} +
+ {activeChipLabel ? ( +

+ {activeChipLabel} selecionado. Backspace remove. +

+ ) : null} + {isPending ? ( +

+ Atualizando resultados… +

+ ) : null}
diff --git a/src/components/campaign/shared/useCampaignListFilterNavigation.ts b/src/components/campaign/shared/useCampaignListFilterNavigation.ts index 1fc4a864..54347862 100644 --- a/src/components/campaign/shared/useCampaignListFilterNavigation.ts +++ b/src/components/campaign/shared/useCampaignListFilterNavigation.ts @@ -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/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 index 1b3097ff..2eb7bf26 100644 --- a/src/utilities/municipality/municipalityOmnibox.ts +++ b/src/utilities/municipality/municipalityOmnibox.ts @@ -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() - 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() + + 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, diff --git a/tests/unit/municipalityOmnibox.unit.spec.ts b/tests/unit/municipalityOmnibox.unit.spec.ts index 80f959c4..94016450 100644 --- a/tests/unit/municipalityOmnibox.unit.spec.ts +++ b/tests/unit/municipalityOmnibox.unit.spec.ts @@ -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 }), + }) + }) })