diff --git a/src/pages/DisputeCardSkeleton.tsx b/src/pages/DisputeCardSkeleton.tsx new file mode 100644 index 0000000..ba026ef --- /dev/null +++ b/src/pages/DisputeCardSkeleton.tsx @@ -0,0 +1,94 @@ +import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +/** + * DisputeCardSkeleton + * ------------------- + * Loading skeleton that mirrors the final shape so the + * first paint doesn't jump when dispute data resolves. + * + * Layout parity (against DisputeCard — populated branch): + * - CardHeader: `flex flex-row items-start justify-between gap-4 pb-3` + * ↳ Left shard: title at `text-base` (h-5) on two lines, capped at + * `max-w-[75%]` so the badge always has room. + * ↳ Right shard: badge pill — `h-6 w-24 !rounded-full shrink-0` + * matches DisputeStateBadge (size="md") shape. + * - CardContent: generic, state-agnostic placeholder (reason paragraph × 2, + * penalty strip, deadline row, tally strip, accordion + * trigger). Every section mirrors the conditional layout + * of the real component. + * - CardFooter: `border-t pt-3 flex items-center justify-between` with + * a caption-sized label shard + action button shard. + * + * Accessibility (WCAG 2.1 AA): + * - `role="status"` + `aria-busy="true"` on the Card root. + * `role="status"` implies `aria-live="polite"` so screen readers + * announce the loading state on mount. + * - A single sr-only message ("Loading dispute details…") is read on + * focus. Individual Skeletons render with `aria-hidden` (the base + * primitive's default) so they are not announced separately. + * - `prefers-reduced-motion` is honored at the level + * (motion-reduce:animate-none) so the shimmer stops for users who + * request reduced motion. + * + * Responsive behavior: + * - All widths are ratios (`w-full`, `w-2/3`, `w-[90%]`) — no fixed + * `px` values that could overflow on narrow viewports. + * - Badge shard pinned with `shrink-0` so its pill width stays stable. + * - Action button shard uses `w-32 sm:w-28` so it reflows gracefully. + */ +export function DisputeCardSkeleton() { + return ( + + Loading dispute details… + + {/* Header: matches DisputeCard — title on the left, badge on the right */} + +
+ {/* Title (text-base, leading-snug ≈ 1.375rem) → h-5 + 2 lines */} + + +
+ {/* DisputeStateBadge (size="md": h-6 w-24 rounded-full) */} + +
+ + {/* Content: generic placeholder valid for any dispute state */} + + {/* Reason paragraph — 2 lines */} +
+ + +
+ + {/* Penalty info strip — rounded-md border, p-3 height */} + + + {/* Deadline row */} + + + {/* Tally row — 2 items side-by-side */} +
+ + +
+ + {/* Audit refs accordion trigger */} + +
+ + {/* Footer: matches border-t + pt-3 + flex justify-between */} + + {/* State label — text-xs */} + + {/* Action button */} + + +
+ ); +} diff --git a/src/pages/__tests__/DisputeCardSkeleton.test.tsx b/src/pages/__tests__/DisputeCardSkeleton.test.tsx new file mode 100644 index 0000000..aea3005 --- /dev/null +++ b/src/pages/__tests__/DisputeCardSkeleton.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { DisputeCardSkeleton } from '../DisputeCardSkeleton'; + +describe('DisputeCardSkeleton', () => { + it('renders the skeleton with correct test id', () => { + render(); + expect(screen.getByTestId('dispute-card-skeleton')).toBeInTheDocument(); + }); + + it('has accessible loading role', () => { + render(); + expect(screen.getByTestId('dispute-card-skeleton')).toHaveAttribute('role', 'status'); + }); + + it('indicates busy state', () => { + render(); + expect(screen.getByTestId('dispute-card-skeleton')).toHaveAttribute('aria-busy', 'true'); + }); + + it('has a sr-only loading message', () => { + render(); + expect(screen.getByText('Loading dispute details…')).toBeInTheDocument(); + }); + + it('renders the header with title shards', () => { + const { container } = render(); + // The CardHeader contains two Skeleton shards for title + badge + const skeletons = container.querySelectorAll('.animate-pulse'); + expect(skeletons.length).toBeGreaterThanOrEqual(8); + }); + + it('renders footer with state label and action button shards', () => { + render(); + // The CardFooter is inside the Card component + expect(screen.getByTestId('dispute-card-skeleton')).toBeInTheDocument(); + }); +});