diff --git a/docs/plans/sidebar-filtros-salvos-proximidade-impl.md b/docs/plans/sidebar-filtros-salvos-proximidade-impl.md new file mode 100644 index 00000000..b6ef0175 --- /dev/null +++ b/docs/plans/sidebar-filtros-salvos-proximidade-impl.md @@ -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) diff --git a/src/components/campaign/shell/CampaignSidebar.tsx b/src/components/campaign/shell/CampaignSidebar.tsx index a9e7b65b..eb961b85 100644 --- a/src/components/campaign/shell/CampaignSidebar.tsx +++ b/src/components/campaign/shell/CampaignSidebar.tsx @@ -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 }) => ( diff --git a/src/components/campaign/shell/MunicipalityNavSavedFilters.tsx b/src/components/campaign/shell/MunicipalityNavSavedFilters.tsx index 8582a346..ea2e8dc6 100644 --- a/src/components/campaign/shell/MunicipalityNavSavedFilters.tsx +++ b/src/components/campaign/shell/MunicipalityNavSavedFilters.tsx @@ -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(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 // 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('a') + ? ((row?.nextElementSibling ?? row?.previousElementSibling)?.querySelector( + '[data-sidebar="menu-sub-button"]', + ) ?? menuItem?.querySelector('a')) + : menuItem?.querySelector('a') removeMunicipalitySavedFilter(entry.href) successor?.focus() @@ -100,52 +73,33 @@ export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () => } return ( - <> - - - {/* Always mounted so `aria-controls` always resolves; `hidden` wins over - the list's own `flex` through tailwind-merge. */} - - {savedFilters.map((entry) => { - const isActive = entry.href === activeHref + + {savedFilters.map((entry) => { + const isActive = entry.href === activeHref - return ( - - - - {entry.name} - - - 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 ( + + + - - - ) - })} - - + {entry.name} + + + 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" + > + + + ) + })} + ) } diff --git a/src/utilities/municipality/municipalitySavedFilters.ts b/src/utilities/municipality/municipalitySavedFilters.ts index 07671d6d..bf99cda7 100644 --- a/src/utilities/municipality/municipalitySavedFilters.ts +++ b/src/utilities/municipality/municipalitySavedFilters.ts @@ -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) => { diff --git a/tests/e2e/campaignSavedFilters.e2e.spec.ts b/tests/e2e/campaignSavedFilters.e2e.spec.ts index dfb42227..bc085206 100644 --- a/tests/e2e/campaignSavedFilters.e2e.spec.ts +++ b/tests/e2e/campaignSavedFilters.e2e.spec.ts @@ -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 . + // Emptying the group unmounts the sub-list, so focus lands on the nav link + // rather than falling to . 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) }) })