diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx
index e23233e9..ee35e237 100644
--- a/src/app/(campaign)/campanha/(app)/layout.tsx
+++ b/src/app/(campaign)/campanha/(app)/layout.tsx
@@ -10,6 +10,7 @@ import {
type BiometricEnrollmentOffer,
} from '@/components/campaign/shell/BiometricEnrollmentToast'
import { CampaignAppScrollChrome } from '@/components/campaign/shell/CampaignAppScrollChrome'
+import { CampaignHomeSearchChromeProvider } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot'
import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext'
@@ -75,7 +76,8 @@ export default async function CampaignAppLayout({ children }: { children: React.
-
+
+
}
@@ -100,7 +102,8 @@ export default async function CampaignAppLayout({ children }: { children: React.
{biometricEnrollment ? : null}
-
+
+
diff --git a/src/components/campaign/dashboard/CampaignHomeSearchChromePublisher.tsx b/src/components/campaign/dashboard/CampaignHomeSearchChromePublisher.tsx
new file mode 100644
index 00000000..cdfd5325
--- /dev/null
+++ b/src/components/campaign/dashboard/CampaignHomeSearchChromePublisher.tsx
@@ -0,0 +1,31 @@
+'use client'
+
+import { useCallback, useMemo } from 'react'
+
+import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
+import { useHomeSearchFocusHistory } from '@/components/campaign/dashboard/useHomeSearchFocusHistory'
+import {
+ useSetCampaignHomeSearchChrome,
+ type CampaignHomeSearchChromeState,
+} from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
+
+/** Publishes Início search chrome to the layout top bar (B106). */
+export const CampaignHomeSearchChromePublisher = () => {
+ const { clear, uiFocused } = useHomeSearch()
+
+ const collapse = useCallback(() => {
+ clear()
+ }, [clear])
+
+ const chrome = useMemo((): CampaignHomeSearchChromeState | null => {
+ if (!uiFocused) {
+ return null
+ }
+ return { focused: true, collapse }
+ }, [collapse, uiFocused])
+
+ useSetCampaignHomeSearchChrome(chrome)
+ useHomeSearchFocusHistory({ focused: uiFocused, onCollapse: collapse })
+
+ return null
+}
diff --git a/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx b/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx
index 3bc0d4ce..a54094c0 100644
--- a/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx
+++ b/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx
@@ -9,6 +9,7 @@ import {
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
+import { CampaignHomeSearchChromePublisher } from '@/components/campaign/dashboard/CampaignHomeSearchChromePublisher'
import {
HomeSearchResultsProvider,
InitialHomeSearchSuggestProvider,
@@ -42,6 +43,7 @@ const CampaignHomeStaffChromeInner = ({
return (
+
{
const { query } = useHomeSearch()
- const { results, resultKind } = useHomeSearchResults()
+ const { results, resultKind, isFetching } = useHomeSearchResults()
+
+ const hasRenderableHits =
+ results.status === 'success' && homeSearchHasAnyHits(results.data)
+ const showSkeleton = isFetching && !hasRenderableHits && results.status !== 'error'
const showEmpty =
query.isActive &&
@@ -18,7 +23,8 @@ export const HomeSearchResultsShell = ({ children }: { children: ReactNode }) =>
return (
<>
- {children}
+ {showSkeleton ? : null}
+ {!showSkeleton ? children : null}
{results.status === 'error' ? (
{results.message}
diff --git a/src/components/campaign/dashboard/HomeSearchSuggestSkeleton.tsx b/src/components/campaign/dashboard/HomeSearchSuggestSkeleton.tsx
new file mode 100644
index 00000000..666fd549
--- /dev/null
+++ b/src/components/campaign/dashboard/HomeSearchSuggestSkeleton.tsx
@@ -0,0 +1,28 @@
+import { Skeleton } from '@/components/ui/skeleton'
+import { HOME_SEARCH_HIT_ROW_WRAPPER_CLASS } from '@/lib/homeSearchUi'
+import { cn } from '@/lib/utils'
+
+const HOME_SEARCH_SUGGEST_SKELETON_ROW_COUNT = 3
+
+const HomeSearchSuggestSkeletonRow = () => (
+
+)
+
+/** B106 — placeholder rows while suggest/search hits are still loading. */
+export const HomeSearchSuggestSkeleton = () => (
+
+ {Array.from({ length: HOME_SEARCH_SUGGEST_SKELETON_ROW_COUNT }, (_, index) => (
+
+ ))}
+
+)
diff --git a/src/components/campaign/dashboard/useHomeSearchFocusHistory.ts b/src/components/campaign/dashboard/useHomeSearchFocusHistory.ts
new file mode 100644
index 00000000..86e45879
--- /dev/null
+++ b/src/components/campaign/dashboard/useHomeSearchFocusHistory.ts
@@ -0,0 +1,70 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+
+import { HOME_SEARCH_FOCUS_HISTORY_KEY } from '@/lib/homeSearchFocusHistory'
+
+/**
+ * B106 — Android / browser back collapses Início search without leaving `/campanha`.
+ * Only active while mounted on the home page publisher.
+ */
+export const useHomeSearchFocusHistory = (input: {
+ focused: boolean
+ onCollapse: () => void
+}): void => {
+ const { focused, onCollapse } = input
+ const historyPushedRef = useRef(false)
+ const closingProgrammaticallyRef = useRef(false)
+ const onCollapseRef = useRef(onCollapse)
+
+ useEffect(() => {
+ onCollapseRef.current = onCollapse
+ }, [onCollapse])
+
+ useEffect(() => {
+ if (!focused || typeof window === 'undefined' || historyPushedRef.current) {
+ return
+ }
+
+ window.history.pushState({ [HOME_SEARCH_FOCUS_HISTORY_KEY]: true }, '')
+ historyPushedRef.current = true
+ }, [focused])
+
+ useEffect(() => {
+ if (focused || !historyPushedRef.current || typeof window === 'undefined') {
+ return
+ }
+
+ if (window.history.state?.[HOME_SEARCH_FOCUS_HISTORY_KEY]) {
+ closingProgrammaticallyRef.current = true
+ window.history.back()
+ }
+ historyPushedRef.current = false
+ }, [focused])
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ const handlePopState = () => {
+ if (closingProgrammaticallyRef.current) {
+ closingProgrammaticallyRef.current = false
+ historyPushedRef.current = false
+ return
+ }
+
+ if (!historyPushedRef.current) {
+ return
+ }
+
+ historyPushedRef.current = false
+ onCollapseRef.current()
+ }
+
+ window.addEventListener('popstate', handlePopState)
+ return () => {
+ window.removeEventListener('popstate', handlePopState)
+ }
+ }, [])
+}
diff --git a/src/components/campaign/shell/CampaignHomeSearchChromeContext.tsx b/src/components/campaign/shell/CampaignHomeSearchChromeContext.tsx
new file mode 100644
index 00000000..909dc5f8
--- /dev/null
+++ b/src/components/campaign/shell/CampaignHomeSearchChromeContext.tsx
@@ -0,0 +1,65 @@
+'use client'
+
+import {
+ createContext,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+ useState,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+} from 'react'
+
+export type CampaignHomeSearchChromeState = {
+ focused: boolean
+ collapse: () => void
+}
+
+type CampaignHomeSearchChromeContextValue = {
+ chrome: CampaignHomeSearchChromeState | null
+ setChrome: Dispatch>
+}
+
+const CampaignHomeSearchChromeContext = createContext(
+ null,
+)
+
+export const CampaignHomeSearchChromeProvider = ({ children }: { children: ReactNode }) => {
+ const [chrome, setChrome] = useState(null)
+ const value = useMemo(() => ({ chrome, setChrome }), [chrome])
+
+ return (
+
+ {children}
+
+ )
+}
+
+const useCampaignHomeSearchChromeContext = (): CampaignHomeSearchChromeContextValue => {
+ const value = useContext(CampaignHomeSearchChromeContext)
+ if (!value) {
+ throw new Error('CampaignHomeSearchChromeProvider is required')
+ }
+ return value
+}
+
+export const useCampaignHomeSearchChrome = (): CampaignHomeSearchChromeState | null =>
+ useCampaignHomeSearchChromeContext().chrome
+
+export const useSetCampaignHomeSearchChrome = (
+ state: CampaignHomeSearchChromeState | null,
+): void => {
+ const { setChrome } = useCampaignHomeSearchChromeContext()
+
+ useLayoutEffect(() => {
+ setChrome(state)
+ }, [setChrome, state])
+
+ useLayoutEffect(
+ () => () => {
+ setChrome(null)
+ },
+ [setChrome],
+ )
+}
diff --git a/src/components/campaign/shell/CampaignMobileTopBar.tsx b/src/components/campaign/shell/CampaignMobileTopBar.tsx
index fc096119..c25cbd8c 100644
--- a/src/components/campaign/shell/CampaignMobileTopBar.tsx
+++ b/src/components/campaign/shell/CampaignMobileTopBar.tsx
@@ -5,6 +5,7 @@ import type { ReactNode } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
+import { useCampaignHomeSearchChrome } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { Button } from '@/components/ui/button'
import { SidebarTrigger } from '@/components/ui/Sidebar'
@@ -15,6 +16,7 @@ import {
wizardFlowChromeAriaLabel,
wizardMunicipalityChromeAriaLabel,
} from '@/lib/campaignWizardCopy'
+import { HOME_SEARCH_COLLAPSE_ARIA_LABEL } from '@/lib/campaignHomeSearchContract'
import { cn } from '@/lib/utils'
const wizardNavButtonClass =
@@ -30,6 +32,7 @@ const wizardTitleSkeletonClass = 'mx-auto motion-reduce:animate-none bg-primary-
export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?: ReactNode }) => {
const chrome = useCampaignWizardChrome()
+ const homeSearchChrome = useCampaignHomeSearchChrome()
const { isPending } = useCampaignListTransition()
if (chrome) {
@@ -123,13 +126,28 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
-
+ {homeSearchChrome?.focused ? (
+
+ ) : (
+
+ )}
Jorge Solla
Campanha · Bahia
diff --git a/src/lib/campaignHomeSearchContract.ts b/src/lib/campaignHomeSearchContract.ts
index 67e3ec55..d5fd4d11 100644
--- a/src/lib/campaignHomeSearchContract.ts
+++ b/src/lib/campaignHomeSearchContract.ts
@@ -24,3 +24,6 @@ export const normalizeHomeSearchRaw = (raw: string): string => raw.trim()
*/
export const homeSearchUiFocused = (input: { inputFocused: boolean; isActive: boolean }): boolean =>
input.inputFocused || input.isActive
+
+/** B106 — mobile top bar back control when Início search is expanded. */
+export const HOME_SEARCH_COLLAPSE_ARIA_LABEL = 'Fechar busca' as const
diff --git a/src/lib/homeSearchFocusHistory.ts b/src/lib/homeSearchFocusHistory.ts
new file mode 100644
index 00000000..72e674a6
--- /dev/null
+++ b/src/lib/homeSearchFocusHistory.ts
@@ -0,0 +1,21 @@
+/** History state mark for Início mobile search focus (B106). */
+export const HOME_SEARCH_FOCUS_HISTORY_KEY = 'teqoHomeSearchFocus' as const
+
+export type HomeSearchFocusHistoryState = {
+ [HOME_SEARCH_FOCUS_HISTORY_KEY]?: true
+}
+
+export const isHomeSearchFocusHistoryState = (state: unknown): state is HomeSearchFocusHistoryState =>
+ typeof state === 'object' &&
+ state !== null &&
+ HOME_SEARCH_FOCUS_HISTORY_KEY in state &&
+ (state as HomeSearchFocusHistoryState)[HOME_SEARCH_FOCUS_HISTORY_KEY] === true
+
+/**
+ * After popstate, the active entry may no longer carry our mark — collapse when
+ * focus mode was open and the browser popped our synthetic entry.
+ */
+export const shouldCollapseHomeSearchOnPopstate = (input: {
+ wasHistoryPushed: boolean
+ closingProgrammatically: boolean
+}): boolean => input.wasHistoryPushed && !input.closingProgrammatically
diff --git a/tests/unit/campaignHomeSearch.unit.spec.tsx b/tests/unit/campaignHomeSearch.unit.spec.tsx
index 1f40f99f..492668b1 100644
--- a/tests/unit/campaignHomeSearch.unit.spec.tsx
+++ b/tests/unit/campaignHomeSearch.unit.spec.tsx
@@ -1,8 +1,10 @@
-import { render, screen } from '@testing-library/react'
-import { describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
+import { HomeSearchResultsProvider } from '@/components/campaign/dashboard/HomeSearchResultsContext'
+import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
const providerValue = {
query: { raw: 'ca', debounced: 'ca', isActive: true },
@@ -15,6 +17,10 @@ const providerValue = {
}
describe('CampaignHomeSearch', () => {
+ afterEach(() => {
+ cleanup()
+ })
+
it('renders search input and results region', () => {
render(
@@ -29,3 +35,30 @@ describe('CampaignHomeSearch', () => {
expect(screen.getByText('Group stub')).toBeTruthy()
})
})
+
+describe('HomeSearchResultsShell', () => {
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('shows skeleton rows while fetching without hits (B106)', () => {
+ render(
+
+
+
+ Group stub
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('home-search-suggest-skeleton')).toBeTruthy()
+ expect(screen.queryByText('Group stub')).toBeNull()
+ })
+})
diff --git a/tests/unit/campaignMobileTopBar.unit.spec.tsx b/tests/unit/campaignMobileTopBar.unit.spec.tsx
index 841070e2..76c92d31 100644
--- a/tests/unit/campaignMobileTopBar.unit.spec.tsx
+++ b/tests/unit/campaignMobileTopBar.unit.spec.tsx
@@ -1,4 +1,4 @@
-import { cleanup, render, screen } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/navigation', () => ({
@@ -9,6 +9,11 @@ vi.mock('next/navigation', () => ({
}))
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
+import {
+ CampaignHomeSearchChromeProvider,
+ useSetCampaignHomeSearchChrome,
+ type CampaignHomeSearchChromeState,
+} from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
import {
CampaignWizardChromeProvider,
useSetCampaignWizardChrome,
@@ -17,27 +22,28 @@ import {
import { SidebarProvider } from '@/components/ui/Sidebar'
import { stubMatchMedia } from '../helpers/matchMedia'
-const ChromeProbe = ({ chrome }: { chrome: CampaignWizardChromeState | null }) => {
+const WizardChromeProbe = ({ chrome }: { chrome: CampaignWizardChromeState | null }) => {
useSetCampaignWizardChrome(chrome)
return null
}
-const renderTopBar = (chrome: CampaignWizardChromeState | null) =>
- render(
-
-
-
-
-
- ,
- )
+const HomeSearchChromeProbe = ({ chrome }: { chrome: CampaignHomeSearchChromeState | null }) => {
+ useSetCampaignHomeSearchChrome(chrome)
+ return null
+}
-const renderWizardTopBar = (chrome: CampaignWizardChromeState) =>
+const renderTopBar = (input: {
+ wizardChrome?: CampaignWizardChromeState | null
+ homeSearchChrome?: CampaignHomeSearchChromeState | null
+}) =>
render(
-
-
+
+
+
+
+
,
)
@@ -53,20 +59,37 @@ describe('CampaignMobileTopBar', () => {
})
it('renders app mode with sidebar trigger when chrome is null', () => {
- const { container } = renderTopBar(null)
+ const { container } = renderTopBar({})
const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
expect(topBar?.getAttribute('data-mode')).toBe('app')
expect(screen.getByText('Jorge Solla')).toBeTruthy()
expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull()
+ expect(screen.queryByRole('button', { name: 'Fechar busca' })).toBeNull()
expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull()
})
+ it('renders app mode with Voltar when home search is focused (B106)', () => {
+ const collapse = vi.fn()
+ const { container } = renderTopBar({
+ homeSearchChrome: { focused: true, collapse },
+ })
+
+ const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
+ expect(topBar?.getAttribute('data-home-search-focused')).toBe('true')
+ expect(screen.queryByRole('button', { name: 'Abrir ou fechar menu da campanha' })).toBeNull()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Fechar busca' }))
+ expect(collapse).toHaveBeenCalledTimes(1)
+ })
+
it('renders wizard entry mode with dismiss control and flow title', () => {
- renderWizardTopBar({
- flowTitle: 'Ajustar votos',
- stepKind: 'entry',
- dismissHref: '/campanha',
+ renderTopBar({
+ wizardChrome: {
+ flowTitle: 'Ajustar votos',
+ stepKind: 'entry',
+ dismissHref: '/campanha',
+ },
})
expect(screen.getByText('Ajustar votos')).toBeTruthy()
@@ -77,12 +100,14 @@ describe('CampaignMobileTopBar', () => {
})
it('renders wizard continue mode with back link and municipality subtitle', () => {
- renderWizardTopBar({
- flowTitle: 'Ajustar votos',
- municipalityLabel: 'Cairu',
- stepKind: 'continue',
- previousHref: '/campanha/acoes/atualizar-votos',
- dismissHref: '/campanha',
+ renderTopBar({
+ wizardChrome: {
+ flowTitle: 'Ajustar votos',
+ municipalityLabel: 'Cairu',
+ stepKind: 'continue',
+ previousHref: '/campanha/acoes/atualizar-votos',
+ dismissHref: '/campanha',
+ },
})
expect(screen.getByRole('link', { name: /Voltar/ }).getAttribute('href')).toBe(
@@ -93,14 +118,16 @@ describe('CampaignMobileTopBar', () => {
})
it('renders skip link instead of dismiss when chained flow defines skip', () => {
- renderWizardTopBar({
- flowTitle: 'Registrar sinal',
- stepKind: 'continue',
- previousHref: '/campanha/acoes/atualizar-votos?municipio=cairu',
- dismissHref: '/campanha',
- skip: {
- label: 'Pular',
- href: '/campanha/acoes/mudar-tendencia?municipio=cairu',
+ renderTopBar({
+ wizardChrome: {
+ flowTitle: 'Registrar sinal',
+ stepKind: 'continue',
+ previousHref: '/campanha/acoes/atualizar-votos?municipio=cairu',
+ dismissHref: '/campanha',
+ skip: {
+ label: 'Pular',
+ href: '/campanha/acoes/mudar-tendencia?municipio=cairu',
+ },
},
})
@@ -114,14 +141,16 @@ describe('CampaignMobileTopBar', () => {
const { rerender } = render(
-
-
+
+
+
+
,
)
@@ -131,16 +160,18 @@ describe('CampaignMobileTopBar', () => {
rerender(
-
-
+
+
+
+
,
)
diff --git a/tests/unit/homeSearchFocusHistory.unit.spec.ts b/tests/unit/homeSearchFocusHistory.unit.spec.ts
new file mode 100644
index 00000000..925928b5
--- /dev/null
+++ b/tests/unit/homeSearchFocusHistory.unit.spec.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ HOME_SEARCH_FOCUS_HISTORY_KEY,
+ isHomeSearchFocusHistoryState,
+ shouldCollapseHomeSearchOnPopstate,
+} from '@/lib/homeSearchFocusHistory'
+
+describe('homeSearchFocusHistory', () => {
+ it('recognizes the focus history mark', () => {
+ expect(isHomeSearchFocusHistoryState({ [HOME_SEARCH_FOCUS_HISTORY_KEY]: true })).toBe(true)
+ expect(isHomeSearchFocusHistoryState({})).toBe(false)
+ expect(isHomeSearchFocusHistoryState(null)).toBe(false)
+ })
+
+ it('collapses on popstate only when focus history was pushed by us', () => {
+ expect(
+ shouldCollapseHomeSearchOnPopstate({
+ wasHistoryPushed: true,
+ closingProgrammatically: false,
+ }),
+ ).toBe(true)
+
+ expect(
+ shouldCollapseHomeSearchOnPopstate({
+ wasHistoryPushed: true,
+ closingProgrammatically: true,
+ }),
+ ).toBe(false)
+
+ expect(
+ shouldCollapseHomeSearchOnPopstate({
+ wasHistoryPushed: false,
+ closingProgrammatically: false,
+ }),
+ ).toBe(false)
+ })
+})