mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
e687be2ad3
commit
3b2be37972
7 changed files with 394 additions and 271 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue