From 84365ddf54f63dbb770e9cc61ad09d9caf4091cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 18:36:28 +0000 Subject: [PATCH] =?UTF-8?q?B106=20=E2=80=94=20Voltar=20no=20header=20mobil?= =?UTF-8?q?e,=20Android=20back=20e=20skeleton=20das=20sugest=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/(campaign)/campanha/(app)/layout.tsx | 7 +- .../CampaignHomeSearchChromePublisher.tsx | 31 ++++ .../dashboard/CampaignHomeStaffChrome.tsx | 2 + .../dashboard/HomeSearchResultsShell.tsx | 10 +- .../dashboard/HomeSearchSuggestSkeleton.tsx | 28 ++++ .../dashboard/useHomeSearchFocusHistory.ts | 70 +++++++++ .../shell/CampaignHomeSearchChromeContext.tsx | 65 +++++++++ .../campaign/shell/CampaignMobileTopBar.tsx | 20 ++- src/lib/campaignHomeSearchContract.ts | 3 + src/lib/homeSearchFocusHistory.ts | 21 +++ tests/unit/campaignHomeSearch.unit.spec.tsx | 37 ++++- tests/unit/campaignMobileTopBar.unit.spec.tsx | 133 +++++++++++------- .../unit/homeSearchFocusHistory.unit.spec.ts | 38 +++++ 13 files changed, 407 insertions(+), 58 deletions(-) create mode 100644 src/components/campaign/dashboard/CampaignHomeSearchChromePublisher.tsx create mode 100644 src/components/campaign/dashboard/HomeSearchSuggestSkeleton.tsx create mode 100644 src/components/campaign/dashboard/useHomeSearchFocusHistory.ts create mode 100644 src/components/campaign/shell/CampaignHomeSearchChromeContext.tsx create mode 100644 src/lib/homeSearchFocusHistory.ts create mode 100644 tests/unit/homeSearchFocusHistory.unit.spec.ts 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) + }) +})