Merge pull request #258 from fsolla/cursor/pool-i253-48e749f5-b609

B124 — Sidebar Municípios: filtros salvos colados (sem disclosure)
This commit is contained in:
Francisco Solla 2026-08-02 15:38:01 -03:00 committed by GitHub
commit 33d775396e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 116 additions and 131 deletions

View file

@ -0,0 +1,67 @@
# Impl: B124 — Sidebar Municípios: filtros salvos colados (sem disclosure)
Status: aprovado
Atualizado em: 2026-08-02
Issue: #253
Intenção: docs/plans/sidebar-filtros-salvos-proximidade.md
Appetite restante: ~0,5 dia eng (dentro)
## Leitura da intenção
- **Outcome:** Atalhos de filtro salvo aparecem sempre visíveis, indentados e colados logo abaixo de Municípios — leem como filhos, não como seção própria.
- **O que NÃO negociar:** sem chevron/disclosure/hover; Apagar+Desfazer intactos; `listQueryMatch` puro; sem serializador de URL no layout `(app)`; a11y (`aria-label` na lista, `aria-current` no ativo).
- **O que reavaliar:** hipótese de editar `Sidebar.tsx` — preferir ajuste local em `MunicipalityNavSavedFilters` via `className` no `SidebarMenuSub`.
## Abordagem recomendada
```mermaid
flowchart TD
A[MunicipalityNavSavedFilters] --> B[Remove disclosure state + chevron]
B --> C[SidebarMenuSub sempre visível]
C --> D[Tighten vertical gap via className]
E[municipalitySavedFilters.ts] --> F[Remove open storage API]
G[e2e] --> H[Replace disclosure test with always-visible reload]
```
**Opções consideradas:**
- **A:** Só esconder chevron mas manter estado open — rejeitada (viola aceite).
- **B:** Mover sub-lista para fora do `SidebarMenuItem` de Municípios — rejeitada (quebra semântica de filho; mais churn).
- **C (recomendada):** Remover disclosure no componente existente; apagar API de open; apertar `mt`/`py` no `SidebarMenuSub` localmente.
**Recomendação:** C — menor diff, dono correto, aceite coberto.
### Componentes / mudanças
- **`MunicipalityNavSavedFilters.tsx`:** remover chevron, `open` state, `useEffect` de auto-open, imports mortos; lista sempre montada; foco no delete aponta para link de Municípios; `className` com `-mt-0.5 py-0` para proximidade.
- **`municipalitySavedFilters.ts`:** remover `OPEN_STORAGE_KEY`, `read/writeMunicipalitySavedFiltersOpen`; manter `removeItem(OPEN_STORAGE_KEY)` em `clear` para limpar legado no logout.
- **`CampaignSidebar.tsx`:** atualizar comentário do slot children (sem disclosure).
- **Migration:** sem migration.
- **UI:** Impeccable B — shape (sub-lista indentada) + craft (gap) + critique (sem controles órfãos).
### Dados → forma
N/A — navegação localStorage inalterada.
## Fases verificáveis
1. **Server/utilities** — remover API de open; limpar exports mortos.
2. **UI** — componente always-on + spacing.
3. **Testes** — ajustar e2e disclosure → always-visible após reload; `pnpm gate:fast`; `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não refatorar `SidebarMenuSub` globalmente.
- Não adicionar rótulo "Filtros salvos".
- Não truncar lista a 4 itens.
## Riscos e mitigação
- **Foco ao apagar último filtro:** resolver sucessor como link `Municípios` no mesmo `li` (já existia como fallback).
- **Legado `localStorage` open:** `clearMunicipalitySavedFilters` no logout remove a chave antiga.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes e2e ajustados (sem disclosure)

View file

@ -48,7 +48,7 @@ const CampaignSidebarLink = ({
item: CampaignNavItem
isActive: boolean
onNavigate: () => void
/** Row affordances that sit beside the link — B18's disclosure and sub-list. */
/** Row affordances below the link — B18's saved-filter sub-list (B124: always on). */
children?: ReactNode
}) => (
<SidebarMenuItem>

View file

@ -1,9 +1,8 @@
'use client'
import { ChevronRightIcon, Trash2Icon } from 'lucide-react'
import { Trash2Icon } from 'lucide-react'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect, useId, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useMunicipalitySavedFilters } from '@/components/campaign/shared/useMunicipalitySavedFilters'
@ -15,10 +14,8 @@ import {
} from '@/components/ui/Sidebar'
import { isSameListHref } from '@/lib/listQueryMatch'
import {
readMunicipalitySavedFiltersOpen,
removeMunicipalitySavedFilter,
saveMunicipalitySavedFilter,
writeMunicipalitySavedFiltersOpen,
type MunicipalitySavedFilter,
} from '@/utilities/municipality/municipalitySavedFilters'
@ -28,18 +25,14 @@ const IGNORED_PARAMS = ['page']
/**
* The saved filters of `/campanha/municipios` (B18), hung under that nav item.
*
* Renders only the disclosure and the sub-list: the nav link above it stays the
* one `CampaignSidebarLink` every other item uses.
* Renders only the sub-list: the nav link above it stays the one
* `CampaignSidebarLink` every other item uses. Always visible when entries exist
* (B124 — no disclosure).
*/
export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () => void }) => {
const savedFilters = useMunicipalitySavedFilters()
const pathname = usePathname()
const searchParams = useSearchParams()
// Read once, not tracked: the store owns the list, this is only the
// disclosure, and the toggle already writes it back.
const [open, setOpen] = useState(readMunicipalitySavedFiltersOpen)
const listId = useId()
const disclosureRef = useRef<HTMLButtonElement>(null)
const query = searchParams.toString()
const currentHref = query ? `${pathname}?${query}` : pathname
@ -47,40 +40,20 @@ export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () =>
isSameListHref(currentHref, entry.href, IGNORED_PARAMS),
)?.href
// Arriving at a saved filter reveals where you are, but only on a real
// navigation into one: opening on every mount would make the persisted
// preference unhonorable for whoever uses saved filters most, since they
// collapse the group, reload, and find it back. The trigger is the URL
// changing rather than `activeHref` appearing, because the store answers
// empty until the client has read `localStorage` — a match surfacing out of
// hydration would otherwise read as a navigation on every single load.
const previousHref = useRef(currentHref)
useEffect(() => {
const navigated = previousHref.current !== currentHref
previousHref.current = currentHref
if (navigated && activeHref) setOpen(true)
}, [currentHref, activeHref])
// No group until there is something in it — and nothing to hydrate either,
// since the store answers empty until the client reads `localStorage`.
if (!savedFilters.length) return null
const toggle = () => {
const next = !open
setOpen(next)
writeMunicipalitySavedFiltersOpen(next)
}
const handleRemove = (entry: MunicipalitySavedFilter) => {
const handleRemove = (entry: MunicipalitySavedFilter, row: HTMLLIElement | null) => {
// The button about to vanish is the one holding focus, which would drop to
// <body> and strand a keyboard user at the top of the page. Resolve the
// survivor BEFORE the removal: emptying the group unmounts this whole
// component, disclosure included, so the fallback has to be the nav link.
const disclosure = disclosureRef.current
// component, so the fallback has to be the nav link.
const menuItem = row?.closest('[data-sidebar="menu-item"]')
const successor =
savedFilters.length > 1
? disclosure
: disclosure?.closest('li')?.querySelector<HTMLElement>('a')
? ((row?.nextElementSibling ?? row?.previousElementSibling)?.querySelector<HTMLElement>(
'[data-sidebar="menu-sub-button"]',
) ?? menuItem?.querySelector<HTMLElement>('a'))
: menuItem?.querySelector<HTMLElement>('a')
removeMunicipalitySavedFilter(entry.href)
successor?.focus()
@ -100,52 +73,33 @@ export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () =>
}
return (
<>
<SidebarMenuAction
ref={disclosureRef}
type="button"
aria-expanded={open}
aria-controls={listId}
aria-label={`${open ? 'Ocultar' : 'Mostrar'} os filtros salvos de Municípios`}
onClick={toggle}
className={open ? 'rotate-90' : undefined}
>
<ChevronRightIcon aria-hidden="true" />
</SidebarMenuAction>
{/* Always mounted so `aria-controls` always resolves; `hidden` wins over
the list's own `flex` through tailwind-merge. */}
<SidebarMenuSub
id={listId}
aria-label="Filtros salvos de Municípios"
className={open ? undefined : 'hidden'}
>
{savedFilters.map((entry) => {
const isActive = entry.href === activeHref
<SidebarMenuSub aria-label="Filtros salvos de Municípios" className="-mt-0.5 py-0">
{savedFilters.map((entry) => {
const isActive = entry.href === activeHref
return (
<SidebarMenuSubItem key={entry.href}>
<SidebarMenuSubButton asChild isActive={isActive}>
<Link
href={entry.href}
onClick={onNavigate}
aria-current={isActive ? 'page' : undefined}
title={entry.name}
>
<span>{entry.name}</span>
</Link>
</SidebarMenuSubButton>
<SidebarMenuAction
type="button"
onClick={() => handleRemove(entry)}
aria-label={`Apagar o filtro salvo ${entry.name}`}
className="top-1 group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100 md:opacity-0"
return (
<SidebarMenuSubItem key={entry.href}>
<SidebarMenuSubButton asChild isActive={isActive}>
<Link
href={entry.href}
onClick={onNavigate}
aria-current={isActive ? 'page' : undefined}
title={entry.name}
>
<Trash2Icon aria-hidden="true" />
</SidebarMenuAction>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
</>
<span>{entry.name}</span>
</Link>
</SidebarMenuSubButton>
<SidebarMenuAction
type="button"
onClick={(event) => handleRemove(entry, event.currentTarget.closest('li'))}
aria-label={`Apagar o filtro salvo ${entry.name}`}
className="top-1 group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100 md:opacity-0"
>
<Trash2Icon aria-hidden="true" />
</SidebarMenuAction>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
)
}

View file

@ -124,41 +124,19 @@ export const removeMunicipalitySavedFilter = (href: string): void => {
write(next)
}
/** Legacy B18 disclosure key — removed by B124; cleared on logout. */
const LEGACY_OPEN_STORAGE_KEY = 'teqo:campaign:municipality-saved-filters-open'
export const clearMunicipalitySavedFilters = (): void => {
if (typeof window === 'undefined') return
try {
localStorage.removeItem(OPEN_STORAGE_KEY)
localStorage.removeItem(LEGACY_OPEN_STORAGE_KEY)
} catch {
// Same reason as below: the preference is a nicety.
// A stale preference is a nicety.
}
write([])
}
const OPEN_STORAGE_KEY = 'teqo:campaign:municipality-saved-filters-open'
/**
* The submenu's disclosure. Open by default — a shortcut nobody can see is a
* shortcut nobody uses, and the group only exists once something is in it.
*/
export const readMunicipalitySavedFiltersOpen = (): boolean => {
if (typeof window === 'undefined') return true
try {
return localStorage.getItem(OPEN_STORAGE_KEY) !== 'false'
} catch {
return true
}
}
export const writeMunicipalitySavedFiltersOpen = (open: boolean): void => {
if (typeof window === 'undefined') return
try {
localStorage.setItem(OPEN_STORAGE_KEY, open ? 'true' : 'false')
} catch {
// A remembered disclosure is a nicety; a full quota must not break the
// toggle itself, which works from React state either way.
}
}
export const subscribeMunicipalitySavedFilters = (onChange: () => void): (() => void) => {
if (typeof window === 'undefined') return () => {}
const handleChange = (event: Event) => {

View file

@ -79,8 +79,8 @@ test.describe('Filtros salvos de Municípios', () => {
const remove = page.getByRole('button', { name: `Apagar o filtro salvo ${RENAMED}` })
await remove.click()
await expect(page.getByRole('link', { name: RENAMED })).toHaveCount(0)
// Emptying the group unmounts the disclosure too, so focus lands on the
// nav link rather than falling to <body>.
// Emptying the group unmounts the sub-list, so focus lands on the nav link
// rather than falling to <body>.
await expect(page.getByRole('link', { name: 'Municípios', exact: true })).toBeFocused()
// Deleting is undoable, which is why it never asks for confirmation.
@ -88,17 +88,14 @@ test.describe('Filtros salvos de Municípios', () => {
await expect(page.getByRole('link', { name: RENAMED })).toBeVisible()
})
test('survives a reload and hides the shortcuts behind the disclosure', async ({
campaign,
page,
}) => {
test('survives a reload with shortcuts always visible', async ({ campaign, page }) => {
// Three loads of a heavy list route, against a dev server shared with the
// other worker — the login redirect alone can outrun the 30 s default.
test.slow()
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Disclosure'),
name: fixtures.value('Coordenadora Sidebar'),
})
const password = coordinator.password
const email = coordinator.email!
@ -114,18 +111,7 @@ test.describe('Filtros salvos de Municípios', () => {
await page.reload()
await expect(shortcut).toBeVisible(NAVIGATION)
await page
.getByRole('button', { name: 'Ocultar os filtros salvos de Municípios' })
.click(NAVIGATION)
await expect(shortcut).toBeHidden()
// The collapsed disclosure is remembered across a reload.
await page.reload()
await expect(page.getByRole('link', { name: SAVED_NAME })).toBeHidden(NAVIGATION)
await page
.getByRole('button', { name: 'Mostrar os filtros salvos de Municípios' })
.click(NAVIGATION)
await expect(page.getByRole('link', { name: SAVED_NAME })).toBeVisible()
await expect(page.getByRole('list', { name: 'Filtros salvos de Municípios' })).toBeVisible()
await expect(page.getByRole('button', { name: /filtros salvos de Municípios/i })).toHaveCount(0)
})
})