Skip to content
Open
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
140 changes: 105 additions & 35 deletions src/components/FormsList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useMemo, useCallback } from 'react';
import { Skeleton } from './Skeleton';
import { Skeleton, SkeletonContainer } from './Skeleton';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
import { useToast } from '@/components/toast/toast-provider';
import { exportData } from '@/utils/export';
Expand Down Expand Up @@ -104,6 +104,108 @@ export function execCommandFallback(text: string): boolean {
return success;
}

// ---------------------------------------------------------------------------
// Skeleton
// ---------------------------------------------------------------------------

/**
* Props for the FormsListSkeleton component.
*/
export interface FormsListSkeletonProps {
/**
* When provided, an error banner is shown inside the skeleton container
* instead of the shimmer rows, matching the loading-with-error UX.
*/
error?: string | null;
}

/**
* FormsListSkeleton — themed shimmer that mirrors the FormsList visual
* structure so there is no layout shift when real content loads.
*
* When `error` is supplied the skeleton rows are replaced by an error
* banner inside the loading container, so AT continues to hear "Loading
* forms" while sighted users see the error.
*
* Accessibility:
* - Wrapped in a `<SkeletonContainer>` with `role="status"` and
* `aria-busy="true"` so AT announces the loading state on mount.
* - All shimmer blocks carry `aria-hidden="true"` via the `<Skeleton>`
* component — they are decorative placeholders.
* - The shimmer animation is suppressed under `prefers-reduced-motion`
* via the project-wide globals.css rule plus `motion-reduce:animate-none`.
*
* Exported separately for use in tests or Next.js `loading.tsx` files.
*/
export const FormsListSkeleton: React.FC<FormsListSkeletonProps> = ({
error = null,
}) => (
<SkeletonContainer label="Loading forms" data-testid="forms-loading">
{error ? (
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
) : (
<>
{/* Filter + Export action bar — mirrors the `justify-between` row */}
<div className="flex flex-wrap items-center justify-between gap-2 mb-4">
{/* Filter buttons group */}
<div
className="flex gap-2"
aria-hidden="true"
data-testid="forms-skeleton-filters"
>
<Skeleton width="w-24" height="h-9" rounded="rounded-lg" />
<Skeleton width="w-24" height="h-9" rounded="rounded-lg" />
<Skeleton width="w-28" height="h-9" rounded="rounded-lg" />
</div>

{/* Export buttons */}
<div
className="flex gap-2"
aria-hidden="true"
data-testid="forms-skeleton-export"
>
<Skeleton width="w-24" height="h-9" rounded="rounded-lg" />
<Skeleton width="w-24" height="h-9" rounded="rounded-lg" />
</div>
</div>

{/* Form list rows — each mirrors a title + id/copy row */}
<ul
data-testid="forms-list-skeleton"
aria-hidden="true"
className="space-y-2"
>
{Array.from({ length: 10 }, (_, index) => (
<li
key={`skeleton-${index}`}
data-testid="forms-skeleton-row"
className="flex items-center justify-between gap-3 py-2"
>
{/* Form title */}
<Skeleton
width="w-48"
height="h-5"
rounded="rounded-md"
/>
{/* Form ID + Copy button — uses <span> to mirror loaded <li> */}
<span className="flex items-center gap-2">
<Skeleton width="w-24" height="h-4" rounded="rounded-md" />
<Skeleton width="w-16" height="h-7" rounded="rounded" />
</span>
</li>
))}
</ul>
</>
)}
</SkeletonContainer>
);

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export const FormsList = ({ forms, isLoading = false, error = null }: FormsListProps) => {
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FormStatus>('All');
Expand All @@ -121,41 +223,9 @@ export const FormsList = ({ forms, isLoading = false, error = null }: FormsListP
const displayedForms = filteredForms.slice(0, page * pageSize);
const hasMore = displayedForms.length < filteredForms.length;

// ── Loading state ──
if (isLoading) {
return (
<div>
<div
role="status"
aria-label="Loading forms"
aria-live="polite"
aria-busy="true"
data-testid="forms-loading"
>
{error ? (
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
) : null}
{!error ? (
<>
<div className="mb-4 flex flex-wrap gap-2" aria-hidden="true">
<Skeleton width="w-24" height="h-9" rounded="rounded-lg" />
<Skeleton width="w-24" height="h-9" rounded="rounded-lg" />
<Skeleton width="w-28" height="h-9" rounded="rounded-lg" />
</div>
<ul data-testid="forms-list-skeleton" aria-hidden="true" className="space-y-2">
{Array.from({ length: 10 }, (_, index) => (
<li key={`skeleton-${index}`} data-testid="forms-skeleton-row" className="py-2">
<Skeleton width="w-full" height="h-5" rounded="rounded-md" className="max-w-[18rem]" />
</li>
))}
</ul>
</>
) : null}
<span className="sr-only">Loading forms</span>
</div>
</div>
);
return <FormsListSkeleton error={error} />;
}

if (error) {
Expand Down
10 changes: 8 additions & 2 deletions src/components/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,12 @@ export const Skeleton: React.FC<SkeletonProps> = ({
// SkeletonContainer
// ---------------------------------------------------------------------------

export interface SkeletonContainerProps {
export interface SkeletonContainerProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Accessible label for the loading region (e.g. "Loading payment stream form").
*/
label: string;
children: React.ReactNode;
className?: string;
}

/**
Expand All @@ -95,8 +94,15 @@ export const SkeletonContainer: React.FC<SkeletonContainerProps> = ({
label,
children,
className = '',
// Destructure out ARIA attributes we control to prevent accidental overrides
role: _role,
'aria-label': _ariaLabel,
'aria-live': _ariaLive,
'aria-busy': _ariaBusy,
...rest
}) => (
<div
{...rest}
role="status"
aria-label={label}
aria-live="polite"
Expand Down
130 changes: 120 additions & 10 deletions src/components/__tests__/FormsList.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, within, fireEvent } from '@testing-library/react';
import { FormsList, Form } from '../FormsList';

const createForms = (count: number): Form[] => {
Expand All @@ -10,14 +10,11 @@ const createForms = (count: number): Form[] => {
}));
};

describe('FormsList Pagination Boundaries', () => {
it('renders the first page of forms', () => {
const forms = createForms(15);
render(<FormsList forms={forms} />);
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(10);
});
// ─────────────────────────────────────────────────────────────────────────────
// Skeleton / layout shift
// ─────────────────────────────────────────────────────────────────────────────

describe('FormsList — skeleton / layout shift', () => {
it('renders a form-shaped loading skeleton with a busy state while loading', () => {
const forms = createForms(3);
render(<FormsList forms={forms} isLoading />);
Expand All @@ -30,6 +27,100 @@ describe('FormsList Pagination Boundaries', () => {
expect(screen.getAllByTestId('forms-skeleton-row')).toHaveLength(10);
});

it('skeleton toolbar mirrors the loaded toolbar to prevent layout shift', () => {
const forms = createForms(3);

// Render skeleton and loaded side-by-side for comparison
const ui = (
<>
<div data-testid="skeleton-area">
<FormsList forms={forms} isLoading />
</div>
<div data-testid="loaded-area">
<FormsList forms={forms} />
</div>
</>
);
render(ui);

const skeletonArea = within(screen.getByTestId('skeleton-area'));
const loadedArea = within(screen.getByTestId('loaded-area'));

// Skeleton has filter group with 3 placeholder buttons
expect(skeletonArea.getByTestId('forms-skeleton-filters')).toBeInTheDocument();
// Skeleton has export group with 2 placeholder buttons
expect(skeletonArea.getByTestId('forms-skeleton-export')).toBeInTheDocument();

// Both skeleton and loaded toolbar use the same flex layout with mb-4
const skeletonToolbar = skeletonArea.getByTestId('forms-skeleton-filters').parentElement!;
const loadedToolbar = loadedArea.getByRole('group', { name: /filter forms/i }).parentElement!;
expect(skeletonToolbar.className).toContain('mb-4');
expect(loadedToolbar.className).toContain('mb-4');
});

it('skeleton rows mirror loaded row structure to prevent layout shift', () => {
const forms = createForms(3);

const ui = (
<>
<div data-testid="skeleton-area">
<FormsList forms={forms} isLoading />
</div>
<div data-testid="loaded-area">
<FormsList forms={forms} />
</div>
</>
);
render(ui);

const skeletonArea = within(screen.getByTestId('skeleton-area'));
const loadedArea = within(screen.getByTestId('loaded-area'));

// Skeleton rows use the same flex-between layout as loaded rows
const skeletonRow = skeletonArea.getAllByTestId('forms-skeleton-row')[0];
const loadedRow = loadedArea.getAllByRole('listitem')[0];

for (const cls of ['flex', 'items-center', 'justify-between', 'gap-3', 'py-2']) {
expect(skeletonRow.className).toContain(cls);
expect(loadedRow.className).toContain(cls);
}
});

it('skeleton rows contain title, id, and copy button placeholders', () => {
const forms = createForms(3);
render(<FormsList forms={forms} isLoading />);

const firstRow = screen.getAllByTestId('forms-skeleton-row')[0];
// Row should have a title skeleton (left side)
const titleSkeleton = firstRow.querySelector(':scope > div[aria-hidden="true"]');
expect(titleSkeleton).toBeInTheDocument();
// Row should have a span with id + copy button skeletons (right side)
const rightGroup = firstRow.querySelector(':scope > span');
expect(rightGroup).toBeInTheDocument();
expect(rightGroup!.className).toContain('flex');
expect(rightGroup!.className).toContain('items-center');
expect(rightGroup!.className).toContain('gap-2');
// Right group should contain two skeleton blocks
const rightSkeletons = rightGroup!.querySelectorAll('[aria-hidden="true"]');
expect(rightSkeletons.length).toBe(2);
});

it('transitions from skeleton wrapper to content wrapper cleanly', () => {
const forms = createForms(3);
const { rerender } = render(<FormsList forms={forms} isLoading />);

// Skeleton is showing
expect(screen.getByTestId('forms-loading')).toBeInTheDocument();
expect(screen.queryByTestId('forms-list')).not.toBeInTheDocument();

// Transition to loaded
rerender(<FormsList forms={forms} isLoading={false} />);

// Skeleton gone, content visible — same container element structure
expect(screen.queryByTestId('forms-loading')).not.toBeInTheDocument();
expect(screen.getByTestId('forms-list')).toBeInTheDocument();
});

it('switches from skeleton to content when loading completes', () => {
const forms = createForms(3);
const { rerender } = render(<FormsList forms={forms} isLoading />);
Expand All @@ -42,12 +133,31 @@ describe('FormsList Pagination Boundaries', () => {
expect(screen.getByText('Form 0')).toBeInTheDocument();
});

it('renders an error state in place of the skeleton when provided', () => {
it('shows the loading container with error banner when both isLoading and error are provided', () => {
const forms = createForms(3);
render(<FormsList forms={forms} isLoading error="Unable to load forms" />);

expect(screen.queryByTestId('forms-list-skeleton')).not.toBeInTheDocument();
// Loading container is shown
expect(screen.getByTestId('forms-loading')).toBeInTheDocument();
// Error message shown inside the loading container
expect(screen.getByText('Unable to load forms')).toBeInTheDocument();
// Skeleton rows are NOT shown when error is present
expect(screen.queryByTestId('forms-skeleton-row')).not.toBeInTheDocument();
// Standalone error state is NOT shown
expect(screen.queryByTestId('forms-error')).not.toBeInTheDocument();
});
});

// ─────────────────────────────────────────────────────────────────────────────
// Pagination Boundaries
// ─────────────────────────────────────────────────────────────────────────────

describe('FormsList Pagination Boundaries', () => {
it('renders the first page of forms', () => {
const forms = createForms(15);
render(<FormsList forms={forms} />);
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(10);
});

it('handles load-more append behavior', () => {
Expand Down
Loading
Loading