From d10fa5103ef98cf67c0d2caed1f37354365b95cc Mon Sep 17 00:00:00 2001 From: lunabeckod-design Date: Thu, 27 Aug 2026 04:33:43 +0000 Subject: [PATCH 1/4] fix(a11y): add aria-current='page' to last breadcrumb item The last breadcrumb item now includes aria-current='page' to properly indicate the current page location to screen reader users, improving accessibility per WCAG 2.3.3 standards. --- src/components/Breadcrumb.tsx | 2 +- src/components/__tests__/Breadcrumb.test.tsx | 21 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index 7f7cf42..fe4dfc8 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -51,7 +51,7 @@ export default function Breadcrumb({ items }: Props) { )} {isLast ? ( - {item.label} + {item.label} ) : ( <> {i === 0 && !showEllipsis && ( diff --git a/src/components/__tests__/Breadcrumb.test.tsx b/src/components/__tests__/Breadcrumb.test.tsx index d96fbe1..b28be74 100644 --- a/src/components/__tests__/Breadcrumb.test.tsx +++ b/src/components/__tests__/Breadcrumb.test.tsx @@ -48,4 +48,25 @@ describe("Breadcrumb", () => { const list = container.querySelector("ol"); expect(list).toBeInTheDocument(); }); + + it("should mark the last item with aria-current='page'", () => { + const items: BreadcrumbItem[] = [ + { label: "Invoices", href: "/dashboard" }, + { label: "Invoice #123" }, + ]; + const { container } = render(); + const lastItem = container.querySelector("span[aria-current='page']"); + expect(lastItem).toBeInTheDocument(); + expect(lastItem).toHaveTextContent("Invoice #123"); + }); + + it("should not mark non-last items with aria-current", () => { + const items: BreadcrumbItem[] = [ + { label: "Invoices", href: "/dashboard" }, + { label: "Invoice #123" }, + ]; + const { container } = render(); + const invoicesLink = screen.getByText("Invoices").closest("a"); + expect(invoicesLink).not.toHaveAttribute("aria-current"); + }); }); From 5df5883800b12471e06ef50de16cebe92cf39a8f Mon Sep 17 00:00:00 2001 From: lunabeckod-design Date: Thu, 27 Aug 2026 04:34:19 +0000 Subject: [PATCH 2/4] fix(a11y): replace skeleton animation with static background on prefers-reduced-motion When prefers-reduced-motion is enabled, the skeleton shimmer animation is replaced with a static muted background to comply with WCAG 2.3.3 and improve accessibility for users sensitive to motion. The change is implemented via CSS media queries for SSR compatibility. --- src/__tests__/Skeleton.test.tsx | 36 +++++++++++++++++++++++++++++++++ src/app/globals.css | 20 ++++++++++++++++++ src/components/Skeleton.tsx | 2 +- 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/Skeleton.test.tsx diff --git a/src/__tests__/Skeleton.test.tsx b/src/__tests__/Skeleton.test.tsx new file mode 100644 index 0000000..15fe317 --- /dev/null +++ b/src/__tests__/Skeleton.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Skeleton } from '@/components/Skeleton'; + +describe('Skeleton', () => { + it('renders with skeleton-shimmer class', () => { + const { container } = render(); + const skeletonDiv = container.querySelector('div'); + expect(skeletonDiv).toHaveClass('skeleton-shimmer'); + }); + + it('applies custom className', () => { + const { container } = render(); + const skeletonDiv = container.querySelector('div'); + expect(skeletonDiv).toHaveClass('h-4'); + expect(skeletonDiv).toHaveClass('w-24'); + expect(skeletonDiv).toHaveClass('skeleton-shimmer'); + }); + + it('has proper base styles', () => { + const { container } = render(); + const skeletonDiv = container.querySelector('div'); + expect(skeletonDiv).toHaveClass('bg-gray-200'); + expect(skeletonDiv).toHaveClass('dark:bg-gray-700'); + expect(skeletonDiv).toHaveClass('rounded'); + }); + + it('renders InvoiceCardSkeleton with proper ARIA attributes', () => { + const { render: customRender } = require('@testing-library/react'); + const { InvoiceCardSkeleton } = require('@/components/Skeleton'); + const { container } = customRender(); + const skeleton = container.querySelector('[role="status"]'); + expect(skeleton).toHaveAttribute('aria-busy', 'true'); + expect(skeleton).toHaveAttribute('aria-label', 'Loading invoice data'); + }); +}); diff --git a/src/app/globals.css b/src/app/globals.css index c992527..cc6074e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -126,6 +126,26 @@ html[data-contrast="high"] .text-indigo-500 { } /* ── Custom animations ─────────────────────────────────────────── */ +@keyframes shimmer { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.skeleton-shimmer { + animation: shimmer 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .skeleton-shimmer { + animation: none; + opacity: 0.6; + } +} + @keyframes slide-up { from { opacity: 0; diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx index 1316ebb..98d1755 100644 --- a/src/components/Skeleton.tsx +++ b/src/components/Skeleton.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; /** Shared animated shimmer base */ -const shimmer = "animate-pulse bg-gray-200 dark:bg-gray-700 rounded"; +const shimmer = "skeleton-shimmer bg-gray-200 dark:bg-gray-700 rounded"; /** Base pulsing rectangle primitive — every skeleton in the app builds on this. */ export function Skeleton({ className = "" }: { className?: string }) { From 5216206e8917ba00cb0f8f0994ec988c560901fc Mon Sep 17 00:00:00 2001 From: lunabeckod-design Date: Thu, 27 Aug 2026 04:34:53 +0000 Subject: [PATCH 3/4] feat: add copy error details button to ErrorBoundary Add a 'Copy error details' button to the ErrorBoundary fallback UI that copies the error message and stack trace to the clipboard. The button shows a 'Copied!' confirmation for 2 seconds after clicking, providing developers and support teams with an easy way to capture error diagnostics without opening DevTools. --- src/__tests__/ErrorBoundary.test.tsx | 121 +++++++++++++++++++++++++++ src/components/ErrorBoundary.tsx | 28 ++++++- 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/ErrorBoundary.test.tsx diff --git a/src/__tests__/ErrorBoundary.test.tsx b/src/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 0000000..c05941a --- /dev/null +++ b/src/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,121 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ErrorBoundary from '@/components/ErrorBoundary'; + +describe('ErrorBoundary', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders children when no error', () => { + render( + +
Test content
+
+ ); + expect(screen.getByText('Test content')).toBeInTheDocument(); + }); + + it('renders error UI when error is caught', () => { + const TestComponent = () => { + throw new Error('Test error'); + }; + + const { container } = render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('displays copy button for error details', () => { + const TestComponent = () => { + throw new Error('Test error message'); + }; + + render( + + + + ); + + const copyButton = screen.getByText('Copy error details'); + expect(copyButton).toBeInTheDocument(); + }); + + it('copies error details to clipboard when button is clicked', async () => { + const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + }; + Object.assign(navigator, { clipboard: mockClipboard }); + + const TestComponent = () => { + throw new Error('Test error message'); + }; + + render( + + + + ); + + const copyButton = screen.getByText('Copy error details'); + await userEvent.click(copyButton); + + await waitFor(() => { + expect(mockClipboard.writeText).toHaveBeenCalled(); + expect(mockClipboard.writeText.mock.calls[0][0]).toContain('Test error message'); + }); + }); + + it('shows copied confirmation for 2 seconds', async () => { + vi.useFakeTimers(); + const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + }; + Object.assign(navigator, { clipboard: mockClipboard }); + + const TestComponent = () => { + throw new Error('Test error'); + }; + + render( + + + + ); + + const copyButton = screen.getByText('Copy error details'); + await userEvent.click(copyButton); + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + + vi.advanceTimersByTime(2000); + + await waitFor(() => { + expect(screen.getByText('Copy error details')).toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it('renders reload and dashboard buttons', () => { + const TestComponent = () => { + throw new Error('Test error'); + }; + + render( + + + + ); + + expect(screen.getByText('Reload Page')).toBeInTheDocument(); + expect(screen.getByText('Go to Dashboard')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 640615e..31fac81 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -12,11 +12,12 @@ interface Props { interface State { error: Error | null; sentryEventId: string | null; + copied: boolean; } /** Root error boundary — wraps the entire app. Captures exceptions to Sentry when configured. */ export default class ErrorBoundary extends Component { - state: State = { error: null, sentryEventId: null }; + state: State = { error: null, sentryEventId: null, copied: false }; static getDerivedStateFromError(error: Error): Partial { return { error }; @@ -36,10 +37,26 @@ export default class ErrorBoundary extends Component { } private handleRetry = () => { - this.setState({ error: null, sentryEventId: null }); + this.setState({ error: null, sentryEventId: null, copied: false }); window.location.reload(); }; + private handleCopyErrorDetails = async () => { + const { error } = this.state; + if (!error) return; + + const errorText = `${error.message}\n${error.stack}`; + try { + await navigator.clipboard.writeText(errorText); + this.setState({ copied: true }); + setTimeout(() => { + this.setState({ copied: false }); + }, 2000); + } catch { + console.error('Failed to copy error details'); + } + }; + render() { const { error, sentryEventId } = this.state; if (!error) { @@ -95,6 +112,13 @@ export default class ErrorBoundary extends Component { Error ID: {sentryEventId}

)} +
} +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > +
+ {message} + {onDismiss && } +
+ {duration && ( +
+ + )}
); }