Lift selected options to the top in shared header multi-filters.

Freeze order on Popover open so rows do not slide under the pointer, and keep the same presentation on the mobile NativeSelect without a snapshot.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 17:17:50 -03:00
parent 768256e4ad
commit 38c310a1db
9 changed files with 378 additions and 34 deletions

View file

@ -27,7 +27,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
## Sistema de listas (Pass 2 W1 — use, não reinvente)
- URL: `utilities/campaignListUrl.ts` (parse/canonicalize/`resolveListUrl`) + módulo por domínio (`municipalityListUrl.ts`, `supporterUi.ts`, `activityUi.ts`, parsers nos `*Data.ts`). Contrato de URL congelado (B18).
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `label` **obrigatório**, `head` opcional (default = `<CampaignTableHead>{label}</CampaignTableHead>`; declare só para alinhamento, `description` do B22 ou ilha de head), `cell`, `mandatory`); heads ricos = ilhas (`municipality/MunicipalitySortableHead` + `MunicipalityHeaderFilter`). Seletor de colunas (B17): passe `columnVisibility` — **um** valor, devolvido por `readCampaignColumnVisibility(listId)` (`utilities/campaignColumnVisibilityCookie.ts`, `server-only`) em quem renderiza a tabela — e ela monta o `CampaignColumnPicker` e filtra por `resolveVisibleColumns`; regra pura em `lib/campaignColumnVisibility.ts`. Só o conjunto de **ocultas** é guardado (`defaultVisible` não existe): coluna nova nasce visível.
- Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `label` **obrigatório**, `head` opcional (default = `<CampaignTableHead>{label}</CampaignTableHead>`; declare só para alinhamento, `description` do B22 ou ilha de head), `cell`, `mandatory`); heads ricos = ilhas de domínio sobre `shared/CampaignSortableHead` + `shared/CampaignHeaderFilterPopover` (multi-seleção: opções marcadas sobem ao topo via `lib/listFilterOptions.ts` — snapshot na abertura; gate = `row.checkbox`; single-select intocado). Seletor de colunas (B17): passe `columnVisibility` — **um** valor, devolvido por `readCampaignColumnVisibility(listId)` (`utilities/campaignColumnVisibilityCookie.ts`, `server-only`) em quem renderiza a tabela — e ela monta o `CampaignColumnPicker` e filtra por `resolveVisibleColumns`; regra pura em `lib/campaignColumnVisibility.ts`. Só o conjunto de **ocultas** é guardado (`defaultVisible` não existe): coluna nova nasce visível.
- Shells: `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPagination`, pending compartilhado em `CampaignListPending.tsx` (dima o resultado, não o controle).
- Relação por chips numa célula: `shared/MunicipalityPortfolioCell` (B34) — sempre montada em `pointer-fine`, Drawer em `pointer-coarse`; costuras `minItems`/`maxItems`/`addableIds`/`draft`/`commitAction`. Serve lideranças e assessores; não escrever uma terceira. Lógica pura em `lib/municipalityPortfolio.ts`, índice em `utilities/municipalityPortfolioIndex.ts`.
- Exceções documentadas: atividades = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora.

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,18 @@
# Opções selecionadas no topo nos filtros do header
Status: rascunho
Atualizado em: 2026-07-25
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos — "Selecionados no topo nos filtros do header"; superfície de coordenação, encaixe em B16 ✓)
Impeccable: B — encaixe no Popover de filtro já entregue (`MunicipalityHeaderFilter`) e no select mobile equivalente; sem rota nova, sem tela nova
Appetite: ~0,25–0,5 dia eng; helper puro client-safe + consumo nos dois controles + unit test; sem migration, sem collection, sem Consent, sem server action
Status: entregue (2026-07-28)
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins — "Selecionados no topo nos filtros do header"; ✓)
Impeccable: B — craft → critique → polish no `CampaignHeaderFilterPopover` compartilhado; `harden`/`optimize` out
Appetite: ~0,25–0,5 dia eng; helper puro client-safe + consumo nos shells compartilhados + unit tests; sem migration, sem collection, sem Consent, sem server action
Responsável: —
## Revisão 2026-07-28 (as-built)
A premissa do plano de 2026-07-25 ("não extrair o head genérico — B21, 3º call site") **já tinha sido paga** por B21/B29/B33: `CampaignHeaderFilterPopover` e `CampaignMobileMultiFilterField` vivem em `shared/` com 4 consumidores de header e 7 montagens mobile. O comportamento entrou nesses **dois** arquivos e alcança municípios, lideranças, territórios e dobradinhas de uma vez — nenhum `*HeaderFilter.tsx` de domínio mudou.
Entrega: `src/lib/listFilterOptions.ts` (`orderFilterOptionsSelectedFirst`) + consumo no Popover (snapshot na abertura; ordem congelada; hairline entre grupos; divisor oculto durante busca) e no mobile (sem snapshot, reordena a cada abertura). Gate multi = `optionRows.every(row => row.checkbox)` (1:1 com multi-seleção hoje; single-select intocado). Specs: `listFilterOptions.unit.spec.ts` + `campaignHeaderFilterPopover.unit.spec.ts` (freeze verificado). Critique: P1 "hairline pula na busca" fechado no polish; P1 "desmarcado acima da linha" aceito (snapshot intencional). Sem migration/action/Consent.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 2 — clareza sob pressão; princípio 8 — Feel the action) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · shells do sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignTransitionAnchor`, `CampaignListPendingBoundary`) · Popover shadcn já em uso.

File diff suppressed because one or more lines are too long

View file

@ -7,6 +7,7 @@ import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignL
import { Checkbox } from '@/components/ui/Checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { orderFilterOptionsSelectedFirst } from '@/lib/listFilterOptions'
import { cn } from '@/lib/utils'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
@ -72,7 +73,22 @@ export const CampaignHeaderFilterPopover = ({
}: CampaignHeaderFilterPopoverProps) => {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
/**
* Selected values at open time. Governs presentation order only — checkboxes
* still read live `row.selected`. Freezing order while open keeps rows from
* sliding under the pointer when the optimistic selection flips (same race
* the B16 "click that undoes itself" bug taught).
*/
const [selectionSnapshot, setSelectionSnapshot] = useState<readonly string[]>([])
const searchable = optionRows.length >= SEARCHABLE_OPTION_THRESHOLD
/**
* Selected-first ordering is multi-select only. Today every multi-select
* consumer marks every option row with `checkbox: true`, and every
* single-select leaves it off — so this gate is 1:1 with selection mode
* without a new prop. Reordering a single-select would push "Todas" under
* the marked value, which the product rejected.
*/
const isMultiSelect = optionRows.length > 0 && optionRows.every((row) => row.checkbox)
const searchIndex = useMemo(
() =>
optionRows.map((row) => ({
@ -82,21 +98,40 @@ export const CampaignHeaderFilterPopover = ({
[optionRows],
)
const needle = normalizeSearchPhrase(query)
const visibleRows = needle
const filteredRows = needle
? searchIndex.filter((entry) => entry.haystack.includes(needle)).map((entry) => entry.row)
: optionRows
// Order only while open: a closed popover keeps the natural facet order, and
// the snapshot is irrelevant until the next open.
const { ordered: visibleRows, selectedCount } =
open && isMultiSelect
? orderFilterOptionsSelectedFirst(filteredRows, selectionSnapshot)
: { ordered: filteredRows, selectedCount: 0 }
// Hide the hairline while searching: filtering already reshapes the list,
// and relocating the group boundary on every keystroke is spatial noise.
// Order still follows the open-time snapshot among matches.
const showSelectedDivider =
isMultiSelect && !needle && selectedCount > 0 && selectedCount < visibleRows.length
const choose = (row: CampaignHeaderFilterRow) => {
row.onChoose()
if (closeOnChoose) setOpen(false)
}
const renderRows = (rows: readonly CampaignHeaderFilterRow[]) =>
rows.map((row) => <FilterRow key={row.value} row={row} onChoose={() => choose(row)} />)
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setQuery('')
if (next) {
setSelectionSnapshot(optionRows.filter((row) => row.selected).map((row) => row.value))
return
}
setQuery('')
setSelectionSnapshot([])
}}
>
<PopoverTrigger asChild>
@ -158,9 +193,15 @@ export const CampaignHeaderFilterPopover = ({
aria-labelledby={`${id}-heading`}
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto"
>
{visibleRows.map((row) => (
<FilterRow key={row.value} row={row} onChoose={() => choose(row)} />
))}
{showSelectedDivider ? (
<>
{renderRows(visibleRows.slice(0, selectedCount))}
<div className="my-1 border-t border-border" role="presentation" />
{renderRows(visibleRows.slice(selectedCount))}
</>
) : (
renderRows(visibleRows)
)}
{visibleRows.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">
{needle ? 'Nenhum resultado encontrado.' : emptyLabel}

View file

@ -2,6 +2,7 @@
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { orderFilterOptionsSelectedFirst } from '@/lib/listFilterOptions'
type CampaignMobileMultiFilterOption = {
value: string
@ -22,25 +23,31 @@ export const CampaignMobileMultiFilterField = ({
options: CampaignMobileMultiFilterOption[]
selected: readonly string[]
onToggle: (value: string) => void
}) => (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<NativeSelect
id={id}
value=""
onChange={(event) => {
if (event.target.value) onToggle(event.target.value)
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
</NativeSelectOption>
{options.map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{selected.includes(option.value) ? `✓ ${option.label}` : option.label}
}) => {
// No snapshot: the NativeSelect closes on every choice, so the next open
// already wants the just-toggled value at the top.
const { ordered } = orderFilterOptionsSelectedFirst(options, selected)
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<NativeSelect
id={id}
value=""
onChange={(event) => {
if (event.target.value) onToggle(event.target.value)
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
)
{ordered.map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{selected.includes(option.value) ? `✓ ${option.label}` : option.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
)
}

View file

@ -0,0 +1,33 @@
/**
* Presentation ordering for multi-select filter option lists: selected values
* rise to the top in their original relative order; the rest keep theirs.
* Pure / client-safe — consumed by the shared header Popover and the mobile
* NativeSelect, never by a server facet loader.
*/
type FilterOptionLike = { value: string }
export const orderFilterOptionsSelectedFirst = <T extends FilterOptionLike>(
options: readonly T[],
selected: readonly string[],
): { ordered: readonly T[]; selectedCount: number } => {
if (selected.length === 0) {
return { ordered: options, selectedCount: 0 }
}
const selectedSet = new Set(selected)
const selectedOptions: T[] = []
const rest: T[] = []
for (const option of options) {
if (selectedSet.has(option.value)) selectedOptions.push(option)
else rest.push(option)
}
const selectedCount = selectedOptions.length
if (selectedCount === 0 || selectedCount === options.length) {
return { ordered: options, selectedCount }
}
return { ordered: [...selectedOptions, ...rest], selectedCount }
}

View file

@ -0,0 +1,202 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { createElement, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/navigation', async (importActual) => ({
...(await importActual()),
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
}))
import {
CampaignHeaderFilterPopover,
type CampaignHeaderFilterRow,
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
afterEach(cleanup)
const labelsInOrder = (nav: HTMLElement) =>
within(nav)
.getAllByRole('link')
.map((link) => link.textContent?.trim() ?? '')
const multiRows = (selected: ReadonlySet<string>): CampaignHeaderFilterRow[] =>
[
{ value: 'a', label: 'Alpha' },
{ value: 'b', label: 'Beta' },
{ value: 'c', label: 'Charlie' },
{ value: 'd', label: 'Delta' },
{ value: 'e', label: 'Echo' },
{ value: 'f', label: 'Foxtrot' },
{ value: 'g', label: 'Golf' },
{ value: 'h', label: 'Hotel' },
].map((option) => ({
...option,
href: `/campanha/municipios?region=${option.value}`,
selected: selected.has(option.value),
checkbox: true,
onChoose: () => undefined,
}))
const singleRows = (selected: string | null): CampaignHeaderFilterRow[] => [
{
value: '',
label: 'Todas',
href: '/campanha/municipios',
selected: selected === null,
onChoose: () => undefined,
},
{
value: 'municipio',
label: 'Município',
href: '/campanha/municipios?kind=municipio',
selected: selected === 'municipio',
onChoose: () => undefined,
},
{
value: 'zona',
label: 'Zona eleitoral',
href: '/campanha/municipios?kind=zona',
selected: selected === 'zona',
onChoose: () => undefined,
},
]
const openFilter = (label = 'Território') => {
fireEvent.click(screen.getByRole('button', { name: new RegExp(`Filtrar.*${label}`) }))
}
const optionNav = () => screen.getByRole('navigation', { name: 'Território' })
/** Popover + external button that replaces the selected set (simulates parent re-render). */
const SnapshotHarness = ({
initialSelected,
nextSelected,
buttonLabel,
}: {
initialSelected: ReadonlySet<string>
nextSelected: ReadonlySet<string>
buttonLabel: string
}) => {
const [selected, setSelected] = useState(() => new Set(initialSelected))
return createElement(
'div',
null,
createElement(CampaignHeaderFilterPopover, {
id: 'filter-region',
label: 'Território',
active: true,
optionRows: multiRows(selected),
}),
createElement(
'button',
{
type: 'button',
onClick: () => setSelected(new Set(nextSelected)),
},
buttonLabel,
),
)
}
describe('CampaignHeaderFilterPopover selected-first ordering', () => {
it('lifts selected options to the top when the popover opens', () => {
render(
createElement(CampaignHeaderFilterPopover, {
id: 'filter-region',
label: 'Território',
active: true,
optionRows: multiRows(new Set(['c', 'a'])),
}),
)
openFilter()
expect(labelsInOrder(optionNav())).toEqual([
'Alpha',
'Charlie',
'Beta',
'Delta',
'Echo',
'Foxtrot',
'Golf',
'Hotel',
])
})
it('freezes order while open when the parent re-renders with a new selection', () => {
render(
createElement(SnapshotHarness, {
initialSelected: new Set(['c', 'a']),
nextSelected: new Set(['a']),
buttonLabel: 'Drop Charlie',
}),
)
openFilter()
expect(labelsInOrder(optionNav()).slice(0, 2)).toEqual(['Alpha', 'Charlie'])
fireEvent.click(screen.getByRole('button', { name: 'Drop Charlie' }))
// Snapshot still has both — Charlie stays at the top even though unchecked.
expect(labelsInOrder(optionNav()).slice(0, 2)).toEqual(['Alpha', 'Charlie'])
expect(screen.getByRole('link', { name: 'Charlie' }).getAttribute('aria-current')).toBeNull()
expect(screen.getByRole('link', { name: 'Alpha' }).getAttribute('aria-current')).toBe('true')
})
it('regroups on the next open after closing', () => {
render(
createElement(SnapshotHarness, {
initialSelected: new Set(['c', 'a']),
nextSelected: new Set(['d']),
buttonLabel: 'Select Delta only',
}),
)
openFilter()
// Close via the trigger (Radix toggles).
openFilter()
fireEvent.click(screen.getByRole('button', { name: 'Select Delta only' }))
openFilter()
expect(labelsInOrder(optionNav())[0]).toBe('Delta')
})
it('keeps selected-first order among search matches', () => {
render(
createElement(CampaignHeaderFilterPopover, {
id: 'filter-region',
label: 'Território',
active: true,
optionRows: multiRows(new Set(['h', 'f'])),
}),
)
openFilter()
fireEvent.change(screen.getByRole('textbox', { name: 'Buscar em Território' }), {
target: { value: 'o' },
})
// Echo / Foxtrot / Golf / Hotel match "o"; selected (Hotel, Foxtrot) first.
expect(labelsInOrder(optionNav())).toEqual(['Foxtrot', 'Hotel', 'Echo', 'Golf'])
})
it('does not reorder single-select rows (no checkbox)', () => {
render(
createElement(CampaignHeaderFilterPopover, {
id: 'filter-kind',
label: 'Tipo',
active: true,
closeOnChoose: true,
optionRows: singleRows('zona'),
}),
)
fireEvent.click(screen.getByRole('button', { name: /Filtrar.*Tipo/ }))
expect(labelsInOrder(screen.getByRole('navigation', { name: 'Tipo' }))).toEqual([
'Todas',
'Município',
'Zona eleitoral',
])
})
})

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { orderFilterOptionsSelectedFirst } from '@/lib/listFilterOptions'
const options = [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
{ value: 'c', label: 'C' },
{ value: 'd', label: 'D' },
] as const
describe('orderFilterOptionsSelectedFirst', () => {
it('returns the same reference when nothing is selected', () => {
const result = orderFilterOptionsSelectedFirst(options, [])
expect(result.ordered).toBe(options)
expect(result.selectedCount).toBe(0)
})
it('returns the same reference when every option is selected', () => {
const result = orderFilterOptionsSelectedFirst(options, ['a', 'b', 'c', 'd'])
expect(result.ordered).toBe(options)
expect(result.selectedCount).toBe(4)
})
it('returns the same reference when selected values are all outside the set', () => {
const result = orderFilterOptionsSelectedFirst(options, ['z', 'y'])
expect(result.ordered).toBe(options)
expect(result.selectedCount).toBe(0)
})
it('lifts selected options to the top in their original relative order', () => {
const result = orderFilterOptionsSelectedFirst(options, ['c', 'a'])
expect(result.ordered.map((option) => option.value)).toEqual(['a', 'c', 'b', 'd'])
expect(result.selectedCount).toBe(2)
})
it('keeps unselected options in their original relative order', () => {
const result = orderFilterOptionsSelectedFirst(options, ['d'])
expect(result.ordered.map((option) => option.value)).toEqual(['d', 'a', 'b', 'c'])
})
it('ignores selected values that are not among the options (stale facet)', () => {
const result = orderFilterOptionsSelectedFirst(options, ['c', 'ghost'])
expect(result.ordered.map((option) => option.value)).toEqual(['c', 'a', 'b', 'd'])
expect(result.selectedCount).toBe(1)
})
it('does not duplicate when selected contains the same value twice', () => {
const result = orderFilterOptionsSelectedFirst(options, ['b', 'b'])
expect(result.ordered.map((option) => option.value)).toEqual(['b', 'a', 'c', 'd'])
expect(result.selectedCount).toBe(1)
})
})