Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions frontend/__tests__/components/DeputiesLoading.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'

import DeputiesLoading from '@/app/deputes/loading'
import { PAGE_SIZE } from '@/app/deputes/DeputiesClient'

describe('DeputiesLoading (route skeleton)', () => {
it('renders one skeleton row per real page row, so the list height does not jump once data lands', () => {
render(<DeputiesLoading />)
expect(screen.getAllByTestId('deputy-row-skeleton')).toHaveLength(PAGE_SIZE)
})

it('exposes aria-busy and a status role for the list region', () => {
render(<DeputiesLoading />)
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-busy', 'true')
})

it('announces a screen-reader label for the deputies list', () => {
render(<DeputiesLoading />)
expect(screen.getByText('Chargement des députés…')).toHaveClass('sr-only')
})

it('renders row skeletons matching the real row grid, all decorative', () => {
const { container } = render(<DeputiesLoading />)
const blocks = container.querySelectorAll('.dp-skeleton-block')
expect(blocks.length).toBeGreaterThan(0)
blocks.forEach(block => expect(block).toHaveAttribute('aria-hidden', 'true'))
})
})
46 changes: 45 additions & 1 deletion frontend/__tests__/components/VotesClient.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

// Explicit factory mock so useSWR is a controllable jest.fn().
Expand All @@ -7,6 +7,7 @@ import useSWR from 'swr'
const mockUseSWR = useSWR as jest.Mock

import { VotesClient } from '@/app/votes/VotesClient'
import { LOADING_INLINE_MS, LOADING_NO_INDICATOR_MS } from '@/lib/loadingPolicy'

const vote = {
vote_id: 'v1',
Expand Down Expand Up @@ -72,3 +73,46 @@ describe('VotesClient pagination', () => {
expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument()
})
})

describe('VotesClient loading treatment (MON-215)', () => {
beforeEach(() => jest.useFakeTimers())
afterEach(() => {
jest.useRealTimers()
jest.clearAllMocks()
})

it('does not show a loader for a fast completion under the no-indicator threshold', () => {
mockUseSWR.mockReturnValue({ data: makeList(120), isLoading: true })
render(<VotesClient initial={makeList(120)} heroStats={heroStats} />)

act(() => {
jest.advanceTimersByTime(LOADING_NO_INDICATOR_MS - 50)
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})

it('shows subtle inline activity between the no-indicator and skeleton thresholds', () => {
mockUseSWR.mockReturnValue({ data: makeList(120), isLoading: true })
render(<VotesClient initial={makeList(120)} heroStats={heroStats} />)

act(() => {
jest.advanceTimersByTime(LOADING_NO_INDICATOR_MS + 50)
})
const status = screen.getByRole('status')
expect(status).toHaveTextContent('Chargement…')
// Inline phase, not the full row-skeleton treatment.
expect(status.querySelector('.dp-skeleton-block')).not.toBeInTheDocument()
})

it('shows the layout-matched row skeleton for a delayed completion', () => {
mockUseSWR.mockReturnValue({ data: makeList(120), isLoading: true })
render(<VotesClient initial={makeList(120)} heroStats={heroStats} />)

act(() => {
jest.advanceTimersByTime(LOADING_INLINE_MS + 50)
})
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-busy', 'true')
expect(status.querySelectorAll('.dp-skeleton-block').length).toBeGreaterThan(0)
})
})
29 changes: 29 additions & 0 deletions frontend/__tests__/components/VotesLoading.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'

import VotesLoading from '@/app/votes/loading'
import { PAGE_SIZE } from '@/app/votes/VotesClient'

describe('VotesLoading (route skeleton)', () => {
it('renders one skeleton row per real page row, so the list height does not jump once data lands', () => {
render(<VotesLoading />)
expect(screen.getAllByTestId('vote-row-skeleton')).toHaveLength(PAGE_SIZE)
})

it('exposes aria-busy and a status role for the list region', () => {
render(<VotesLoading />)
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-busy', 'true')
})

it('announces a screen-reader label for the vote list', () => {
render(<VotesLoading />)
expect(screen.getByText('Chargement des scrutins…')).toHaveClass('sr-only')
})

it('renders row skeletons matching the real row grid, all decorative', () => {
const { container } = render(<VotesLoading />)
const blocks = container.querySelectorAll('.dp-skeleton-block')
expect(blocks.length).toBeGreaterThan(0)
blocks.forEach(block => expect(block).toHaveAttribute('aria-hidden', 'true'))
})
})
2 changes: 1 addition & 1 deletion frontend/src/app/deputes/DeputiesClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { departmentCode, departmentLabel } from '@/lib/departments'
type DeputyList = { total: number; items: Deputy[]; limit: number; offset: number }
type SortKey = 'nom' | 'region' | 'parti'

const PAGE_SIZE = 10
export const PAGE_SIZE = 10
const NAVY = 'var(--dp-text)'
const CREAM = 'var(--dp-page-bg)'
const LINE = 'var(--dp-border)'
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/app/deputes/DeputyRowSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { SkeletonBlock } from '@/components/ui/ContentSkeleton'

/**
* Mirrors a real deputy row's grid (DeputiesClient.tsx): avatar + name/department
* on the left, group dot+label, arrow — so the skeleton doesn't reflow into the
* real rows once data lands.
*/
export function DeputyRowSkeleton() {
return (
<div
data-testid="deputy-row-skeleton"
className="grid grid-cols-[1fr_20px] sm:grid-cols-[1fr_260px_34px] gap-3 sm:gap-[18px] px-4 sm:px-[26px] py-[13px]"
style={{ borderBottom: '1px solid var(--dp-track-bg)' }}
>
<div className="flex items-center gap-3.5 min-w-0">
<SkeletonBlock className="w-10 h-10 rounded-full shrink-0" />
<div className="flex flex-col gap-2 min-w-0">
<SkeletonBlock className="h-4 w-36" />
<SkeletonBlock className="h-3 w-24" />
</div>
</div>
<div className="hidden sm:flex items-center gap-2.5">
<SkeletonBlock className="w-[9px] h-[9px] rounded-full shrink-0" />
<SkeletonBlock className="h-3.5 w-28" />
</div>
<SkeletonBlock className="hidden sm:block w-[17px] h-[17px]" />
</div>
)
}
53 changes: 53 additions & 0 deletions frontend/src/app/deputes/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ContentSkeleton, SkeletonBlock } from '@/components/ui/ContentSkeleton'
import { DeputyRowSkeleton } from './DeputyRowSkeleton'
import { PAGE_SIZE } from './DeputiesClient'

// /deputes has no client-side loading state of its own (the list is filtered
// client-side over data already fetched server-side) — the only moment a user
// ever sees a loading treatment here is this route-level Suspense fallback
// while the server awaits fetchAllDeputies(). Approximates the hero's height
// too, not just the list, so landing here doesn't cause a large layout jump
// once the real page replaces it. Matches DeputiesClient's own PAGE_SIZE (the
// real per-page row count) rather than an arbitrary placeholder, for the same
// CLS reason as votes/loading.tsx.
const ROW_COUNT = PAGE_SIZE

export default function DeputiesLoading() {
return (
<div style={{ background: 'var(--dp-page-bg)', minHeight: '100vh' }}>
<div
className="px-5 sm:px-14 pt-8 sm:pt-[50px] pb-8 sm:pb-10"
style={{
background: 'linear-gradient(180deg,var(--dp-card-bg) 0%,var(--dp-page-bg) 100%)',
borderBottom: '1px solid var(--dp-border-subtle)',
}}
>
<div style={{ maxWidth: 1180, margin: '0 auto' }}>
<SkeletonBlock className="h-3 w-40 mb-4" />
<SkeletonBlock className="h-10 w-full max-w-[600px] mb-3" />
<SkeletonBlock className="h-4 w-full max-w-[480px] mb-7" />
<SkeletonBlock className="h-[54px] w-full max-w-[720px] rounded-[10px]" />
</div>
</div>

<div className="px-5 sm:px-14 pt-8 pb-14 sm:pb-[72px]">
<div style={{ maxWidth: 1180, margin: '0 auto' }}>
<ContentSkeleton label="Chargement des députés…">
<div
style={{
background: 'var(--dp-card-bg)',
border: '1px solid var(--dp-border)',
borderRadius: 12,
overflow: 'hidden',
}}
>
{Array.from({ length: ROW_COUNT }).map((_, i) => (
<DeputyRowSkeleton key={i} />
))}
</div>
</ContentSkeleton>
</div>
</div>
</div>
)
}
29 changes: 29 additions & 0 deletions frontend/src/app/votes/VoteRowSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { SkeletonBlock } from '@/components/ui/ContentSkeleton'

/**
* Mirrors a real vote row's grid (VotesClient.tsx): date, title+subtitle, theme
* badge, result badge+meter, arrow. Shared by the route-level loading skeleton
* (initial navigation) and VotesClient's own client-side refetch skeleton
* (filter/pagination changes), so both stay in sync with the real layout.
*/
export function VoteRowSkeleton() {
return (
<div
data-testid="vote-row-skeleton"
className="grid grid-cols-1 sm:grid-cols-[100px_1fr_180px_260px_36px] gap-1.5 sm:gap-4 px-4 sm:px-[26px] py-4 sm:py-[18px]"
style={{ borderBottom: '1px solid var(--dp-track-bg)' }}
>
<SkeletonBlock className="hidden sm:block h-3 w-14" />
<div className="flex flex-col gap-2 min-w-0">
<SkeletonBlock className="h-4 w-4/5" />
<SkeletonBlock className="h-3 w-2/5" />
</div>
<SkeletonBlock className="hidden sm:block h-6 w-24 rounded-full" />
<div className="flex flex-col gap-2">
<SkeletonBlock className="h-5 w-36 rounded-full" />
<SkeletonBlock className="h-[5px] w-full rounded-full" />
</div>
<SkeletonBlock className="hidden sm:block w-4 h-4" />
</div>
)
}
21 changes: 18 additions & 3 deletions frontend/src/app/votes/VotesClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import Link from 'next/link'
import { api, Vote } from '@/lib/api'
import { formatDate, themeColors } from '@/lib/utils'
import { themeSlug } from '@/lib/themes'
import { AsyncStatus } from '@/components/ui/AsyncStatus'
import { ContentSkeleton } from '@/components/ui/ContentSkeleton'
import { useLoadingPhase } from '@/lib/loadingPolicy'
import { VoteRowSkeleton } from './VoteRowSkeleton'

const SKELETON_ROW_COUNT = 8

type VoteList = { total: number; items: Vote[]; limit: number; offset: number }
type HeroStat = { value: string; label: string }
Expand All @@ -23,7 +29,7 @@ const THEMES = [
'Autre',
]

const PAGE_SIZE = 50
export const PAGE_SIZE = 50

function shortDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })
Expand Down Expand Up @@ -67,6 +73,7 @@ export function VotesClient({ initial, heroStats }: { initial: VoteList; heroSta
() => api.votes.list({ result: result || undefined, theme: theme || undefined, search: search || undefined, limit: PAGE_SIZE, offset }),
{ keepPreviousData: true }
)
const loadingPhase = useLoadingPhase(isLoading)

const votes = data?.items ?? (search ? [] : initial.items)
const total = data?.total ?? (search ? 0 : initial.total)
Expand Down Expand Up @@ -212,8 +219,16 @@ export function VotesClient({ initial, heroStats }: { initial: VoteList; heroSta
<span>Date</span><span>Scrutin</span><span>Thème</span><span>Résultat</span><span></span>
</div>

{isLoading ? (
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--dp-text-muted)', fontSize: 14 }}>Chargement…</div>
{loadingPhase === 'content' ? (
<ContentSkeleton label="Chargement des scrutins…">
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, i) => (
<VoteRowSkeleton key={i} />
))}
</ContentSkeleton>
) : loadingPhase === 'inline' ? (
<div style={{ padding: '20px 0' }}>
<AsyncStatus status="Chargement…" phase="inline" className="flex justify-center" />
</div>
) : votes.length === 0 ? (
<div style={{ padding: '40px 26px', textAlign: 'center', color: 'var(--dp-text-muted)', fontSize: 14 }}>Aucun scrutin trouvé.</div>
) : (
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/app/votes/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ContentSkeleton, SkeletonBlock } from '@/components/ui/ContentSkeleton'
import { VoteRowSkeleton } from './VoteRowSkeleton'
import { PAGE_SIZE } from './VotesClient'

// Route-level Suspense fallback for the initial navigation, while the server
// awaits VotesPage's api.votes.list() calls. Distinct from VotesClient's own
// isLoading-gated skeleton, which covers client-side filter/pagination
// refetches after the page has already mounted.
//
// Matches VotesClient's own PAGE_SIZE (not a smaller placeholder count):
// VotesPage fetches and renders up to PAGE_SIZE rows with no client-side
// windowing, so a shorter skeleton would grow substantially once the real
// list mounts, causing a large, visible layout jump on first navigation.
const ROW_COUNT = PAGE_SIZE

export default function VotesLoading() {
return (
<div style={{ background: 'var(--dp-page-bg)', minHeight: '100vh' }}>
<div
className="px-5 sm:px-14 pt-8 sm:pt-[50px] pb-8 sm:pb-10"
style={{
background: 'linear-gradient(180deg,var(--dp-card-bg) 0%,var(--dp-page-bg) 100%)',
borderBottom: '1px solid var(--dp-border-subtle)',
}}
>
<div style={{ maxWidth: 1180, margin: '0 auto' }}>
<SkeletonBlock className="h-3 w-36 mb-4" />
<SkeletonBlock className="h-10 w-full max-w-[600px] mb-3" />
<SkeletonBlock className="h-4 w-full max-w-[480px] mb-7" />
<SkeletonBlock className="h-[54px] w-full max-w-[720px] rounded-[10px]" />
</div>
</div>

<div className="px-5 sm:px-14 pt-8 pb-14 sm:pb-[72px]">
<div style={{ maxWidth: 1180, margin: '0 auto' }}>
<ContentSkeleton label="Chargement des scrutins…">
<div
style={{
background: 'var(--dp-card-bg)',
border: '1px solid var(--dp-border)',
borderRadius: 12,
overflow: 'hidden',
}}
>
{Array.from({ length: ROW_COUNT }).map((_, i) => (
<VoteRowSkeleton key={i} />
))}
</div>
</ContentSkeleton>
</div>
</div>
</div>
)
}
Loading