B106 — Voltar no header mobile, Android back e skeleton das sugestões

- CampaignHomeSearchChromeContext + publisher no Início
- CampaignMobileTopBar troca SidebarTrigger por Voltar quando uiFocused
- useHomeSearchFocusHistory com pushState/popstate para back do SO
- HomeSearchSuggestSkeleton enquanto isFetching sem hits
- Unit tests para history helper, top bar e skeleton

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 18:36:28 +00:00
parent 3babd78d73
commit 84365ddf54
No known key found for this signature in database
13 changed files with 407 additions and 58 deletions

View file

@ -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.
<CampaignSidebar user={campaignUserShellView(user)} />
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<CampaignWizardChromeProvider>
<CampaignQuickActionContextProvider>
<CampaignHomeSearchChromeProvider>
<CampaignQuickActionContextProvider>
<CampaignListPendingBoundary>
<CampaignMobileTopBar
notificationBell={<CampaignNotificationBellSlot user={user} />}
@ -100,7 +102,8 @@ export default async function CampaignAppLayout({ children }: { children: React.
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
</CampaignListPendingBoundary>
</CampaignQuickActionContextProvider>
</CampaignQuickActionContextProvider>
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</SidebarInset>
</SidebarProvider>

View file

@ -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
}

View file

@ -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 (
<div className="h-full min-h-0 w-full" data-home-focused={focused || undefined}>
<CampaignHomeSearchChromePublisher />
<CampaignHomeLayout
actions={actions}
focused={focused}

View file

@ -3,12 +3,17 @@
import type { ReactNode } from 'react'
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
import { HomeSearchSuggestSkeleton } from '@/components/campaign/dashboard/HomeSearchSuggestSkeleton'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import { homeSearchHasAnyHits } from '@/lib/campaignHomeSearchHits'
export const HomeSearchResultsShell = ({ children }: { children: ReactNode }) => {
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 ? <HomeSearchSuggestSkeleton /> : null}
{!showSkeleton ? children : null}
{results.status === 'error' ? (
<p className="text-sm text-destructive" role="alert">
{results.message}

View file

@ -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 = () => (
<div className={cn(HOME_SEARCH_HIT_ROW_WRAPPER_CLASS, 'flex min-h-11 items-center gap-3 py-2.5')}>
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-4 w-3/5 max-w-[12rem]" />
<Skeleton className="h-3 w-2/5 max-w-[8rem]" />
</div>
</div>
)
/** B106 — placeholder rows while suggest/search hits are still loading. */
export const HomeSearchSuggestSkeleton = () => (
<div
data-slot="home-search-suggest-skeleton"
data-testid="home-search-suggest-skeleton"
className="flex flex-col"
aria-hidden
>
{Array.from({ length: HOME_SEARCH_SUGGEST_SKELETON_ROW_COUNT }, (_, index) => (
<HomeSearchSuggestSkeletonRow key={index} />
))}
</div>
)

View file

@ -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)
}
}, [])
}

View file

@ -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<SetStateAction<CampaignHomeSearchChromeState | null>>
}
const CampaignHomeSearchChromeContext = createContext<CampaignHomeSearchChromeContextValue | null>(
null,
)
export const CampaignHomeSearchChromeProvider = ({ children }: { children: ReactNode }) => {
const [chrome, setChrome] = useState<CampaignHomeSearchChromeState | null>(null)
const value = useMemo(() => ({ chrome, setChrome }), [chrome])
return (
<CampaignHomeSearchChromeContext.Provider value={value}>
{children}
</CampaignHomeSearchChromeContext.Provider>
)
}
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],
)
}

View file

@ -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?:
<header
data-slot="campaign-mobile-top-bar"
data-mode="app"
data-home-search-focused={homeSearchChrome?.focused || undefined}
aria-label={WIZARD_APP_TOP_BAR_ARIA_LABEL}
className={cn(
'flex min-h-14 shrink-0 items-center gap-3 bg-primary px-4 text-primary-foreground md:hidden print:hidden',
'pt-[max(0px,env(safe-area-inset-top))]',
)}
>
<SidebarTrigger className="text-primary-foreground" />
{homeSearchChrome?.focused ? (
<Button
type="button"
variant="ghost"
size="sm"
className={wizardNavButtonClass}
aria-label={HOME_SEARCH_COLLAPSE_ARIA_LABEL}
onClick={homeSearchChrome.collapse}
>
<ArrowLeft className="size-4 shrink-0" aria-hidden />
Voltar
</Button>
) : (
<SidebarTrigger className="text-primary-foreground" />
)}
<div className="min-w-0 flex-1 leading-tight">
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
<span className="block truncate text-xs text-primary-foreground/80">Campanha · Bahia</span>

View file

@ -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

View file

@ -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

View file

@ -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(
<HomeSearchProvider value={providerValue}>
@ -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(
<HomeSearchProvider value={providerValue}>
<HomeSearchResultsProvider
value={{
results: { status: 'loading' },
isFetching: true,
resultKind: 'idle',
}}
>
<HomeSearchResultsShell>
<p>Group stub</p>
</HomeSearchResultsShell>
</HomeSearchResultsProvider>
</HomeSearchProvider>,
)
expect(screen.getByTestId('home-search-suggest-skeleton')).toBeTruthy()
expect(screen.queryByText('Group stub')).toBeNull()
})
})

View file

@ -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(
<SidebarProvider>
<CampaignWizardChromeProvider>
<ChromeProbe chrome={chrome} />
<CampaignMobileTopBar />
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
const HomeSearchChromeProbe = ({ chrome }: { chrome: CampaignHomeSearchChromeState | null }) => {
useSetCampaignHomeSearchChrome(chrome)
return null
}
const renderWizardTopBar = (chrome: CampaignWizardChromeState) =>
const renderTopBar = (input: {
wizardChrome?: CampaignWizardChromeState | null
homeSearchChrome?: CampaignHomeSearchChromeState | null
}) =>
render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<ChromeProbe chrome={chrome} />
<CampaignMobileTopBar />
<CampaignHomeSearchChromeProvider>
<WizardChromeProbe chrome={input.wizardChrome ?? null} />
<HomeSearchChromeProbe chrome={input.homeSearchChrome ?? null} />
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
@ -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(
<SidebarProvider>
<CampaignWizardChromeProvider>
<ChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
stepKind: 'entry',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
<CampaignHomeSearchChromeProvider>
<WizardChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
stepKind: 'entry',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
@ -131,16 +160,18 @@ describe('CampaignMobileTopBar', () => {
rerender(
<SidebarProvider>
<CampaignWizardChromeProvider>
<ChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
municipalityLabel: 'Cairu',
stepKind: 'continue',
previousHref: '/campanha/acoes/atualizar-votos',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
<CampaignHomeSearchChromeProvider>
<WizardChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
municipalityLabel: 'Cairu',
stepKind: 'continue',
previousHref: '/campanha/acoes/atualizar-votos',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</SidebarProvider>,
)

View file

@ -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)
})
})