From 7bb7b5e261df7083e9ad59bf21b231671982b4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 15:31:02 -0300 Subject: [PATCH 1/2] B47: staff-only home search input with focused mode hiding action strip. Debounced omnibox chassis and empty results region for B48+; no hit providers yet. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 5 +- docs/plans/busca-global-inicio-input.md | 4 +- docs/roadmap.md | 8 +- src/app/(campaign)/campanha/(app)/page.tsx | 25 +++-- .../campaign/dashboard/CampaignHomeLayout.tsx | 16 ++- .../campaign/dashboard/CampaignHomeSearch.tsx | 45 ++++++++ .../dashboard/CampaignHomeStaffChrome.tsx | 36 +++++++ .../campaign/dashboard/HomeSearchContext.tsx | 25 +++++ .../campaign/dashboard/useHomeSearchQuery.ts | 61 +++++++++++ src/lib/campaignHomeSearchContract.ts | 16 +++ tests/e2e/campaignHomeActions.e2e.spec.ts | 39 +++++++ tests/unit/campaignHomeSearch.unit.spec.tsx | 40 +++++++ .../unit/campaignHomeSearchQuery.unit.spec.ts | 101 ++++++++++++++++++ 13 files changed, 401 insertions(+), 20 deletions(-) create mode 100644 src/components/campaign/dashboard/CampaignHomeSearch.tsx create mode 100644 src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx create mode 100644 src/components/campaign/dashboard/HomeSearchContext.tsx create mode 100644 src/components/campaign/dashboard/useHomeSearchQuery.ts create mode 100644 src/lib/campaignHomeSearchContract.ts create mode 100644 tests/unit/campaignHomeSearch.unit.spec.tsx create mode 100644 tests/unit/campaignHomeSearchQuery.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index ca53e017..d86199fb 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -8,9 +8,10 @@ alwaysApply: false ## Status - **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md). -- **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` ancora `CampaignHomeActions` na metade inferior do viewport em mobile (`min-h-full` no shell + spacer `flex-1` acima da strip; clearance existente `pb-24` + bottom nav fixa, sem `position: fixed` na strip) e no topo em `md+` (`md:min-h-0`). Slots `data-slot="home-actions"` e opcional `searchSlot` → `data-slot="home-search"` (ordem documento mobile ações→busca; desktop busca→ações via `order-1`/`order-2`/`order-3`) para **B47**. Wire em `campanha/(app)/page.tsx`. Unit `campaignHomeLayout.unit.spec.ts`; e2e `campaignHomeActions` verde. Sem migration. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md). Próximo: **B47** busca global. +- **B47 entregue em código (2026-07-29):** busca global do Início (UX-1) — input staff-only com debounce 250 ms (`HOME_SEARCH_DEBOUNCE_MS`), sem botão Pesquisar; modo focado quando query debounced trim ≥2 chars esconde strip B45 e spacer thumb-zone (`CampaignHomeLayout` `focused`). Ilha `CampaignHomeStaffChrome` + `CampaignHomeSearch` + `HomeSearchContext` / `useHomeSearch` para grupos B48+; contrato em `src/lib/campaignHomeSearchContract.ts`. Leader continua sem omnibox (`CampaignHomeLayout` seco). Escape limpa. Sem migration. Plano: [`busca-global-inicio-input.md`](../../../docs/plans/busca-global-inicio-input.md). Próximo: **B48** resultados Municípios. +- **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` ancora `CampaignHomeActions` na metade inferior do viewport em mobile (`min-h-full` no shell + spacer `flex-1` acima da strip; clearance existente `pb-24` + bottom nav fixa, sem `position: fixed` na strip) e no topo em `md+` (`md:min-h-0`). Slots `data-slot="home-actions"` e opcional `searchSlot` → `data-slot="home-search"` (ordem documento mobile ações→busca; desktop busca→ações via `order-1`/`order-2`/`order-3`) para **B47** ✓. Wire em `campanha/(app)/page.tsx`. Unit `campaignHomeLayout.unit.spec.ts`; e2e `campaignHomeActions` verde. Sem migration. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md). - **B45 entregue em código (2026-07-29):** catálogo de ações do Início por persona (UX-1 chassis) — `src/lib/campaignHomeActions.ts` (`homeActionsForRole`, `toHomeActionButtonProps`) + ilha client `CampaignHomeActions` (ícones Lucide no client; RSC passa `role` + `uncoveredMunicipalitiesHref` de `buildMunicipalityListHref`). Staff (coordinator/candidate/advisor): 6 ações; `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; demais inertes (`cursor-default`). Leader: 2 ações; `my-contacts` → `/campanha/contatos`. Seção "O que você quer fazer?" acima da strip B44. Unit `campaignHomeActions.unit.spec.ts`; e2e `campaignHomeActions.e2e.spec.ts`. Sem migration. Plano: [`catalogo-acoes-inicio-por-persona.md`](../../../docs/plans/catalogo-acoes-inicio-por-persona.md). -- **B46–B55 registrados no roadmap (2026-07-29):** ~~thumb-zone do Início (**B46**)~~ entregue; busca global fatiada (**B47** input → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 ✓ → B46 ✓ → B47 → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only na busca; clique = rotas de detalhe existentes; TIs no grupo Municípios. +- **B46–B55 registrados no roadmap (2026-07-29):** ~~thumb-zone do Início (**B46**)~~ entregue; busca global fatiada (**B47** input ✓ → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 ✓ → B46 ✓ → B47 ✓ → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only na busca; clique = rotas de detalhe existentes; TIs no grupo Municípios. - **B44 entregue em código (2026-07-29):** primitivo UX-1 — `CampaignHomeActionButton` (círculo `bg-muted` + ícone + label; um só controle focável) e `CampaignHomeActionStrip` (`overflow-x-auto`, `touch-pan-x`, snap). Descrição: Tooltip shadcn em ponteiro fino; long-press (~450ms) abre Drawer em ponteiro grosso (`useSyncExternalStore` + `matchMedia`, um alvo no DOM) sem disparar click (`src/lib/campaignLongPress.ts`). Montado no Início por **B45** ✓. Specs: `campaignLongPress.unit.spec.tsx`, `campaignHomeActionButton.unit.spec.tsx`. Plano: [`botao-acao-inicio-strip.md`](../../../docs/plans/botao-acao-inicio-strip.md). - **B43 entregue em código (2026-07-29):** chassis UX-1 — `/campanha` fica só shell (`CampaignPageShell` vazio); dashboard staff move para `/campanha/quadro` (corpo idêntico ao antigo Início); `LeaderContactsPanel` em `/campanha/contatos`; nav staff ganha **Quadro** (sidebar; fora do bottom nav mobile); liderança **Início** + **Meus contatos**; `requireCampaignPageActor` redireciona leader bloqueado para `/campanha/contatos` (não para o Início blank). Sem migration. Plano: [`inicio-em-branco-quadro.md`](../../../docs/plans/inicio-em-branco-quadro.md). Desbloqueia **B44** / **B45**. - **Sessão observada CG 2026-07-29 (Bloco B):** 1º hands-on ~54 min. Achados U1–U8 / O9–O11 — não rola; mental model **Ação→Local→Quem**; confirmações invisíveis; forms precisam ser contínuos; hit = lista sem assessor × votos 2022; mapa ≠ mesa de trabalho. Pivot: tabelas=bulk; operação=fluxos lineares tipo Nubank. Snapshot [`sessao-observada-coordenador-2026-07-29-snapshot.md`](../../../docs/plans/sessao-observada-coordenador-2026-07-29-snapshot.md); CUSTOMER.md/OST atualizados. Prazo soft: manejável até reunião assessoria **03/08**. **Chassis:** ~~B43~~ ✓ → ~~B44~~ ✓ → ~~B45~~ ✓ → ~~B46~~ ✓ → **B47–B55** busca global (wizards depois). diff --git a/docs/plans/busca-global-inicio-input.md b/docs/plans/busca-global-inicio-input.md index 210a9f18..6a2a502e 100644 --- a/docs/plans/busca-global-inicio-input.md +++ b/docs/plans/busca-global-inicio-input.md @@ -1,7 +1,7 @@ # Busca global do Início — input e modo resultados -Status: rascunho -Atualizado em: 2026-07-29 +Status: entregue (2026-07-29) +Atualizado em: 2026-07-29 — as-built: `CampaignHomeStaffChrome` (client) + `CampaignHomeSearch` + `useHomeSearchQuery` + `HomeSearchContext`; contrato `src/lib/campaignHomeSearchContract.ts` (debounce 250 ms, mín. 2 chars); staff-only em `page.tsx`; modo focado esconde strip/spacer via `CampaignHomeLayout` `focused`; região de resultados vazia (hits = B48+); DOM mantém ordem B46 (`order-*`); sem endpoint; Escape limpa query. Unit `campaignHomeSearchQuery` + `campaignHomeSearch`; e2e B47 em `campaignHomeActions`. Sem migration. Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B47 — busca global) Impeccable: C — superfície nova de busca no Início (expand/collapse + empty results region) Appetite: ~1–1,5 dia eng; ilha client + debounce + chrome de modo focado; sem provider de hits ainda diff --git a/docs/roadmap.md b/docs/roadmap.md index 0e6f2edc..10de82ea 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-29 — fill-in Cenário junto aos filtros (municípios) ✓; B46 ✓ (thumb-zone ações no Início UX-1); B58 registrado (polimento strip); B56–B57 registrados; B47–B55 busca global fatiada. +Atualizado em: 2026-07-29 — B47 ✓ (busca global input + modo focado no Início); fill-in Cenário junto aos filtros (municípios) ✓; B46 ✓; B58 registrado; B48–B55 resultados busca. Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -83,13 +83,13 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re ### Demais itens abertos -- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → **B58** (polimento strip) → **B47–B55** (busca global fatiada) · **B56** (resumo total+cobertura no topo do Início); **B57** delta 7d = futuro/baixa prioridade; wizards com escrita = fatias seguintes. +- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → **B58** (polimento strip) → **B48–B55** (resultados busca) · **B56** (resumo total+cobertura no topo do Início); **B57** delta 7d = futuro/baixa prioridade; wizards com escrita = fatias seguintes. - ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md) - ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md) - ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md) - **B58** Polimento visual da strip de ações do Início — remover heading "O que você quer fazer?"; rótulos curtos (Ajustar votos, Registrar sinal, …; atalho → **"Ver esquecidos"** _(proposto — validar com produto)_); scale discreto no círculo (hover/long-press) + opacity no active; scrollbar horizontal oculta (pan preservado) · Depende de B45 ✓ · ~0,5d · Janela 1 · Paralelizável com ~~B46~~ ✓ · Impeccable B · [plano](plans/polimento-strip-acoes-inicio.md) - ~~**B46** Posição dos botões de ação (thumb zone)~~ — **entregue 2026-07-29** (`CampaignHomeLayout`: coluna `min-h-full` + spacer mobile; strip em `data-slot="home-actions"` na metade inferior do viewport; `md+` no topo; slot `searchSlot` + `data-slot="home-search"` para B47; sem migration) · [plano](plans/posicao-botoes-acao-inicio-thumb-zone.md) -- **B47** Busca global — input (debounce, sem botão; modo focado: sobe e esconde o resto) · staff-only · Depende de B46 ✓ · ~1–1,5d · Janela 1 · Impeccable C · [plano](plans/busca-global-inicio-input.md) +- ~~**B47** Busca global — input (debounce, sem botão; modo focado: sobe e esconde o resto)~~ — **entregue 2026-07-29** (`CampaignHomeStaffChrome` + `CampaignHomeSearch` + `useHomeSearchQuery`; debounce 250 ms; query ativa ≥2 chars; `HomeSearchContext` para B48+; staff-only via `isStaffCampaignRole`; Escape limpa; região `aria-live` sem hits ainda; sem migration) · [plano](plans/busca-global-inicio-input.md) - **B48** Busca — resultados Municípios (+ TIs no mesmo grupo; flag de prioridade alinhada; “2022” / agregado à direita; sem card) · Depende de B47 · ~1–1,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-municipios.md) - **B49** Busca — resultados Lideranças · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-liderancas.md) - **B50** Busca — resultados Assessores (access B19) · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-assessores.md) @@ -205,7 +205,7 @@ flowchart TD B45n["B45 ✓ Catálogo ações
por persona"] B58n["B58 Polish strip
ações Início"] B46n["B46 ✓ Thumb zone
ações Início"] - B47n["B47 Busca global
input + modo focado"] + B47n["B47 ✓ Busca global
input + modo focado"] B48n["B48 Busca
Municípios + TIs"] B49n["B49 Busca Lideranças"] B50n["B50 Busca Assessores"] diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx index a3f81222..62ab9ab7 100644 --- a/src/app/(campaign)/campanha/(app)/page.tsx +++ b/src/app/(campaign)/campanha/(app)/page.tsx @@ -1,5 +1,6 @@ import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHomeActions' import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout' +import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { isStaffCampaignRole } from '@/lib/campaignRoles' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' @@ -10,20 +11,26 @@ export const dynamic = 'force-dynamic' export default async function CampaignHomePage() { const user = await requireCampaignPageActor() - const uncoveredMunicipalitiesHref = isStaffCampaignRole(user.role) + const staff = isStaffCampaignRole(user.role) + + const uncoveredMunicipalitiesHref = staff ? buildMunicipalityListHref({ page: 1, coverage: 'sem_assessor', sort: 'votos' }, 1) : undefined + const actions = ( + + ) + return ( - - } - /> + {staff ? ( + + ) : ( + + )} ) } diff --git a/src/components/campaign/dashboard/CampaignHomeLayout.tsx b/src/components/campaign/dashboard/CampaignHomeLayout.tsx index 49d30b37..f4620ffb 100644 --- a/src/components/campaign/dashboard/CampaignHomeLayout.tsx +++ b/src/components/campaign/dashboard/CampaignHomeLayout.tsx @@ -1,19 +1,29 @@ import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + export const CampaignHomeLayout = ({ actions, searchSlot, + focused = false, }: { actions: ReactNode searchSlot?: ReactNode + /** When true (trimmed debounced query length ≥ min), hide thumb-zone spacer and action strip. */ + focused?: boolean }) => (
-
-
+ {focused ? null : ( +
+ )} +
{actions}
{searchSlot ? ( -
+
{searchSlot}
) : null} diff --git a/src/components/campaign/dashboard/CampaignHomeSearch.tsx b/src/components/campaign/dashboard/CampaignHomeSearch.tsx new file mode 100644 index 00000000..e2bf24b4 --- /dev/null +++ b/src/components/campaign/dashboard/CampaignHomeSearch.tsx @@ -0,0 +1,45 @@ +'use client' + +import type { ReactNode } from 'react' + +import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext' +import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' + +const HOME_SEARCH_INPUT_ID = 'homeSearchQuery' +const HOME_SEARCH_LABEL = 'Buscar na campanha' + +export const CampaignHomeSearch = ({ children }: { children?: ReactNode }) => { + const { query, setRaw, clear, isDebouncing } = useHomeSearch() + const ariaBusy = isDebouncing || undefined + + return ( +
+ setRaw(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault() + clear() + } + }} + autoComplete="off" + enterKeyHint="search" + aria-busy={ariaBusy} + /> +
+ {children} +
+
+ ) +} diff --git a/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx b/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx new file mode 100644 index 00000000..bf30b273 --- /dev/null +++ b/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx @@ -0,0 +1,36 @@ +'use client' + +import type { ReactNode } from 'react' + +import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout' +import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch' +import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext' +import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery' +import { cn } from '@/lib/utils' + +export const CampaignHomeStaffChrome = ({ + actions, + searchResults, +}: { + actions: ReactNode + /** Result groups plug in here from B48+. */ + searchResults?: ReactNode +}) => { + const searchState = useHomeSearchQuery() + const focused = searchState.query.isActive + + return ( + +
+ {searchResults}} + /> +
+
+ ) +} diff --git a/src/components/campaign/dashboard/HomeSearchContext.tsx b/src/components/campaign/dashboard/HomeSearchContext.tsx new file mode 100644 index 00000000..a2a542f2 --- /dev/null +++ b/src/components/campaign/dashboard/HomeSearchContext.tsx @@ -0,0 +1,25 @@ +'use client' + +import { createContext, useContext, type ReactNode } from 'react' + +import type { HomeSearchController } from '@/components/campaign/dashboard/useHomeSearchQuery' + +export type HomeSearchContextValue = HomeSearchController + +const HomeSearchContext = createContext(null) + +export const HomeSearchProvider = ({ + value, + children, +}: { + value: HomeSearchContextValue + children: ReactNode +}) => {children} + +export const useHomeSearch = (): HomeSearchContextValue => { + const ctx = useContext(HomeSearchContext) + if (!ctx) { + throw new Error('useHomeSearch must be used within HomeSearchProvider') + } + return ctx +} diff --git a/src/components/campaign/dashboard/useHomeSearchQuery.ts b/src/components/campaign/dashboard/useHomeSearchQuery.ts new file mode 100644 index 00000000..a131ad33 --- /dev/null +++ b/src/components/campaign/dashboard/useHomeSearchQuery.ts @@ -0,0 +1,61 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { + HOME_SEARCH_DEBOUNCE_MS, + homeSearchQueryIsActive, + normalizeHomeSearchRaw, + type HomeSearchQuery, +} from '@/lib/campaignHomeSearchContract' + +export type HomeSearchController = { + query: HomeSearchQuery + setRaw: (next: string) => void + clear: () => void + isDebouncing: boolean +} + +export const useHomeSearchQuery = (): HomeSearchController => { + const [raw, setRaw] = useState('') + const [debounced, setDebounced] = useState('') + + useEffect(() => { + if (raw === debounced) { + return + } + const handle = window.setTimeout(() => { + setDebounced(raw) + }, HOME_SEARCH_DEBOUNCE_MS) + return () => { + window.clearTimeout(handle) + } + }, [raw, debounced]) + + const trimmedDebounced = normalizeHomeSearchRaw(debounced) + const isDebouncing = raw !== debounced + + const query = useMemo( + (): HomeSearchQuery => ({ + raw, + debounced: trimmedDebounced, + isActive: homeSearchQueryIsActive(trimmedDebounced), + }), + [raw, trimmedDebounced], + ) + + const clear = useCallback(() => { + setRaw('') + setDebounced('') + }, []) + + return useMemo( + () => ({ + query, + setRaw, + clear, + isDebouncing, + }), + [query, clear, isDebouncing], + ) +} diff --git a/src/lib/campaignHomeSearchContract.ts b/src/lib/campaignHomeSearchContract.ts new file mode 100644 index 00000000..eb76702c --- /dev/null +++ b/src/lib/campaignHomeSearchContract.ts @@ -0,0 +1,16 @@ +/** Debounce for Início omnibox — shorter than list search (1000 ms RSC round-trips). */ +export const HOME_SEARCH_DEBOUNCE_MS = 250 + +/** Minimum trimmed length before focused mode and result providers run. */ +const HOME_SEARCH_MIN_QUERY_LENGTH = 2 + +export type HomeSearchQuery = { + raw: string + debounced: string + isActive: boolean +} + +export const homeSearchQueryIsActive = (trimmedDebounced: string): boolean => + trimmedDebounced.length >= HOME_SEARCH_MIN_QUERY_LENGTH + +export const normalizeHomeSearchRaw = (raw: string): string => raw.trim() diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts index 7abdf310..77d0caca 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -9,6 +9,45 @@ const staffActionLabels = [ 'Ver quem ainda não está coberto', ] as const +test.describe('Início — busca global (B47)', () => { + test('staff sees campaign search on home', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Geral'), + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + await expect(page.getByLabel('Buscar na campanha')).toBeVisible() + }) + + test('leader does not see campaign search on home', async ({ campaign, page }) => { + const { fixtures } = campaign + const leader = await fixtures.createCampaignUser('leader', { + name: fixtures.value('Liderança'), + }) + + await campaign.login(page, leader.email!, leader.password) + await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0) + }) + + test('staff focused search hides action strip after debounce', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Geral'), + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + const heading = page.getByRole('heading', { name: 'O que você quer fazer?', exact: true }) + await expect(heading).toBeVisible() + + await page.getByLabel('Buscar na campanha').fill('ca') + await expect(heading).toBeHidden({ timeout: 5000 }) + + await page.getByLabel('Buscar na campanha').fill('') + await expect(heading).toBeVisible({ timeout: 5000 }) + }) +}) + test.describe('Início — catálogo de ações (B45)', () => { test('staff sees six home actions and can open municipalities without coverage', async ({ campaign, diff --git a/tests/unit/campaignHomeSearch.unit.spec.tsx b/tests/unit/campaignHomeSearch.unit.spec.tsx new file mode 100644 index 00000000..a9f1f126 --- /dev/null +++ b/tests/unit/campaignHomeSearch.unit.spec.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout' +import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch' +import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext' + +const providerValue = { + query: { raw: 'ca', debounced: 'ca', isActive: true }, + setRaw: vi.fn(), + clear: vi.fn(), + isDebouncing: false, +} + +describe('CampaignHomeLayout focused mode', () => { + it('hides actions and thumb spacer when focused', () => { + const { container } = render( + Actions block

} searchSlot={

Search

} />, + ) + + expect(container.querySelector('[data-slot="home-thumb-spacer"]')).toBeNull() + expect(container.querySelector('[data-slot="home-actions"]')?.className).toContain('hidden') + }) +}) + +describe('CampaignHomeSearch', () => { + it('renders search input and results region', () => { + render( + + +

Group stub

+
+
, + ) + + expect(screen.getByLabelText('Buscar na campanha')).toBeTruthy() + expect(screen.getByRole('region', { name: 'Resultados da busca' })).toBeTruthy() + expect(screen.getByText('Group stub')).toBeTruthy() + }) +}) diff --git a/tests/unit/campaignHomeSearchQuery.unit.spec.ts b/tests/unit/campaignHomeSearchQuery.unit.spec.ts new file mode 100644 index 00000000..25458ddb --- /dev/null +++ b/tests/unit/campaignHomeSearchQuery.unit.spec.ts @@ -0,0 +1,101 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery' +import { HOME_SEARCH_DEBOUNCE_MS } from '@/lib/campaignHomeSearchContract' + +describe('useHomeSearchQuery', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('debounces raw input before updating debounced and isActive', () => { + const { result } = renderHook(() => useHomeSearchQuery()) + + act(() => { + result.current.setRaw('ca') + }) + + expect(result.current.query.debounced).toBe('') + expect(result.current.query.isActive).toBe(false) + + act(() => { + vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS) + }) + + expect(result.current.query.debounced).toBe('ca') + expect(result.current.query.isActive).toBe(true) + }) + + it('does not activate until trimmed debounced length meets minimum', () => { + const { result } = renderHook(() => useHomeSearchQuery()) + + act(() => { + result.current.setRaw('a') + }) + act(() => { + vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS) + }) + + expect(result.current.query.isActive).toBe(false) + + act(() => { + result.current.setRaw('ab') + }) + act(() => { + vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS) + }) + + expect(result.current.query.isActive).toBe(true) + }) + + it('trims debounced value and treats whitespace-only as inactive', () => { + const { result } = renderHook(() => useHomeSearchQuery()) + + act(() => { + result.current.setRaw(' ca ') + }) + act(() => { + vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS) + }) + + expect(result.current.query.debounced).toBe('ca') + expect(result.current.query.isActive).toBe(true) + + act(() => { + result.current.setRaw(' ') + }) + act(() => { + vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS) + }) + + expect(result.current.query.debounced).toBe('') + expect(result.current.query.isActive).toBe(false) + }) + + it('clear resets raw, debounced, and debouncing state', () => { + const { result } = renderHook(() => useHomeSearchQuery()) + + act(() => { + result.current.setRaw('cairu') + }) + act(() => { + vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS) + }) + + expect(result.current.query.isActive).toBe(true) + + act(() => { + result.current.clear() + }) + + expect(result.current.query.raw).toBe('') + expect(result.current.query.debounced).toBe('') + expect(result.current.query.isActive).toBe(false) + expect(result.current.isDebouncing).toBe(false) + }) +}) From 243a583e5d955ab76783d824c3d897e13edf37b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 15:32:09 -0300 Subject: [PATCH 2/2] docs: record B47 simplify deferrals in home search plan. --- docs/plans/busca-global-inicio-input.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/plans/busca-global-inicio-input.md b/docs/plans/busca-global-inicio-input.md index 6a2a502e..9ae2e9ee 100644 --- a/docs/plans/busca-global-inicio-input.md +++ b/docs/plans/busca-global-inicio-input.md @@ -93,6 +93,13 @@ Componentes: - **`?q=` na URL do Início** (shareable). Revisitar se mesa pedir mandar link da busca. - **Busca na liderança (apoiadores).** Revisitar se leader pedir achar contato sem rolar a lista. +- **Contexto / boundary só com query debounced para grupos B48+** (evitar re-render por keystroke nos providers). Revisitar ao plugar o primeiro provider com trabalho não trivial em `searchResults`. +- **`useDebouncedState` compartilhado com `AsyncSearchCombobox`.** Revisitar no 3º call site client-only com o mesmo contrato de debounce. +- **Fundir wrapper `CampaignHomeStaffChrome` em `CampaignHomeLayout`.** Revisitar se `data-home-focused` ganhar CSS além de `focused` prop (B48 layout). + +## Já resolvido no simplify pós-B47 (não reabrir) + +- Estado `isDebouncing` derivado (`raw !== debounced`); `HomeSearchController` como tipo único; JSDoc de `focused`; `aria-busy` deduplicado no input. ## Referências