From b4a0509151e849c6f8ca57d949bc46fff958d4f5 Mon Sep 17 00:00:00 2001 From: okontemple96-dotcom Date: Thu, 27 Aug 2026 14:27:38 +0000 Subject: [PATCH 1/4] fix(a11y): add visible focus ring to TextInput for keyboard navigation Adds focus-visible:ring-2 and focus-visible:ring-indigo-500 classes to TextInput component to ensure keyboard-only users can clearly see which field is focused, meeting WCAG 2.1 AA compliance requirements. The focus ring is only visible on keyboard focus (via :focus-visible), not on mouse clicks. Closes #571 --- src/__tests__/TextInput.test.tsx | 6 ++++++ src/components/TextInput.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/__tests__/TextInput.test.tsx b/src/__tests__/TextInput.test.tsx index 618b108..c4f8a50 100644 --- a/src/__tests__/TextInput.test.tsx +++ b/src/__tests__/TextInput.test.tsx @@ -53,4 +53,10 @@ describe('TextInput', () => { render(); expect(screen.queryByRole('paragraph')).not.toBeInTheDocument(); }); + + it('has visible focus ring on keyboard focus', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('focus-visible:ring-2', 'focus-visible:ring-indigo-500'); + }); }); diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx index 56a4f2b..6cdbab8 100644 --- a/src/components/TextInput.tsx +++ b/src/components/TextInput.tsx @@ -10,7 +10,7 @@ export default function TextInput({ label, error, id, ...rest }: Props) { Date: Thu, 27 Aug 2026 14:28:08 +0000 Subject: [PATCH 2/4] fix(component): deduplicate toasts in rapid succession Implements toast deduplication in ToastContainer to prevent duplicate toasts when identical events fire within milliseconds. Toasts with the same message and type are now collapsed into a single toast, and subsequent duplicates reset the auto-dismiss timer of the existing toast. Closes #572 --- src/__tests__/ToastContainer.test.tsx | 90 +++++++++++++++++++++++++++ src/components/ToastContainer.tsx | 32 +++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/ToastContainer.test.tsx diff --git a/src/__tests__/ToastContainer.test.tsx b/src/__tests__/ToastContainer.test.tsx new file mode 100644 index 0000000..2eb774b --- /dev/null +++ b/src/__tests__/ToastContainer.test.tsx @@ -0,0 +1,90 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import ToastContainer from '@/components/ToastContainer'; + +describe('ToastContainer', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('renders no toasts when none are added', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('deduplicates identical toasts fired in rapid succession', () => { + render(); + + const addToast = (window as any).__toastContainer?.addToast; + if (!addToast) throw new Error('addToast not exposed'); + + addToast('Saved!', 'success'); + addToast('Saved!', 'success'); + addToast('Saved!', 'success'); + + const toastMessages = screen.getAllByText('Saved!'); + expect(toastMessages).toHaveLength(1); + }); + + it('does not deduplicate toasts with different messages', () => { + render(); + + const addToast = (window as any).__toastContainer?.addToast; + if (!addToast) throw new Error('addToast not exposed'); + + addToast('Saved!', 'success'); + addToast('Error!', 'error'); + + expect(screen.getByText('Saved!')).toBeInTheDocument(); + expect(screen.getByText('Error!')).toBeInTheDocument(); + }); + + it('does not deduplicate toasts with different types', () => { + render(); + + const addToast = (window as any).__toastContainer?.addToast; + if (!addToast) throw new Error('addToast not exposed'); + + addToast('Updated', 'success'); + addToast('Updated', 'error'); + + const toastMessages = screen.getAllByText('Updated'); + expect(toastMessages).toHaveLength(2); + }); + + it('resets auto-dismiss timer when duplicate toast is added', () => { + render(); + + const addToast = (window as any).__toastContainer?.addToast; + if (!addToast) throw new Error('addToast not exposed'); + + addToast('Saving...', 'info'); + + jest.advanceTimersByTime(4000); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + + addToast('Saving...', 'info'); + jest.advanceTimersByTime(4000); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + + jest.advanceTimersByTime(1000); + expect(screen.queryByText('Saving...')).not.toBeInTheDocument(); + }); + + it('auto-dismisses toasts after 5 seconds', () => { + render(); + + const addToast = (window as any).__toastContainer?.addToast; + if (!addToast) throw new Error('addToast not exposed'); + + addToast('Processed', 'success'); + expect(screen.getByText('Processed')).toBeInTheDocument(); + + jest.advanceTimersByTime(5000); + expect(screen.queryByText('Processed')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ToastContainer.tsx b/src/components/ToastContainer.tsx index af54b15..dbaf4ca 100644 --- a/src/components/ToastContainer.tsx +++ b/src/components/ToastContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import Toast from './Toast'; export interface ToastMessage { @@ -18,16 +18,40 @@ export interface ToastMessage { */ export default function ToastContainer() { const [toasts, setToasts] = useState([]); + const dedupeTimersRef = useRef>(new Map()); + const dismissTimersRef = useRef>(new Map()); const addToast = useCallback((message: string, type: 'success' | 'error' | 'info' = 'info') => { + const dedupeKey = `${message}|${type}`; + const existingToast = toasts.find((t) => t.message === message && t.type === type); + + if (existingToast) { + // Reset auto-dismiss timer for the existing toast + const oldTimer = dismissTimersRef.current.get(existingToast.id); + if (oldTimer) clearTimeout(oldTimer); + + const newTimer = setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== existingToast.id)); + dismissTimersRef.current.delete(existingToast.id); + }, 5_000); + dismissTimersRef.current.set(existingToast.id, newTimer); + return; + } + const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; setToasts((prev) => [...prev, { id, message, type }]); + // Cancel any pending dedupe timer for this key + const pendingDedupeTimer = dedupeTimersRef.current.get(dedupeKey); + if (pendingDedupeTimer) clearTimeout(pendingDedupeTimer); + // Auto-dismiss after 5 seconds - setTimeout(() => { + const dismissTimer = setTimeout(() => { setToasts((prev) => prev.filter((t) => t.id !== id)); + dismissTimersRef.current.delete(id); }, 5_000); - }, []); + dismissTimersRef.current.set(id, dismissTimer); + }, [toasts]); const dismissToast = useCallback((id: string) => { setToasts((prev) => prev.filter((t) => t.id !== id)); @@ -38,6 +62,8 @@ export default function ToastContainer() { (window as any).__toastContainer = { addToast }; return () => { delete (window as any).__toastContainer; + dedupeTimersRef.current.forEach((timer) => clearTimeout(timer)); + dismissTimersRef.current.forEach((timer) => clearTimeout(timer)); }; }, [addToast]); From 731c68e3556e1a9210c0fb8192bb09e9db2bfe9d Mon Sep 17 00:00:00 2001 From: okontemple96-dotcom Date: Thu, 27 Aug 2026 14:28:48 +0000 Subject: [PATCH 3/4] test(a11y): verify aria-describedby wiring in FormInputs Adds comprehensive test coverage for aria-describedby attributes on all FormInputs components (TextInput, NumberInput, Textarea, Select, DatePicker, Toggle, Checkbox). Ensures error and helper text are properly linked to their respective input elements for screen reader users. Closes #570 --- src/__tests__/FormInputs.test.tsx | 171 ++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/__tests__/FormInputs.test.tsx diff --git a/src/__tests__/FormInputs.test.tsx b/src/__tests__/FormInputs.test.tsx new file mode 100644 index 0000000..8211266 --- /dev/null +++ b/src/__tests__/FormInputs.test.tsx @@ -0,0 +1,171 @@ +import { render, screen } from '@testing-library/react'; +import { + TextInput, + NumberInput, + Textarea, + Select, + DatePicker, + Toggle, + Checkbox, +} from '@/components/FormInputs'; + +describe('FormInputs - aria-describedby', () => { + describe('TextInput', () => { + it('sets aria-describedby pointing to error element', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-describedby', 'email-error'); + expect(screen.getByText('Required')).toHaveAttribute('id', 'email-error'); + }); + + it('sets aria-describedby pointing to helper text element', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-describedby', 'email-helper'); + expect(screen.getByText('Your email address')).toHaveAttribute('id', 'email-helper'); + }); + + it('does not set aria-describedby when no error or helper text', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).not.toHaveAttribute('aria-describedby'); + }); + + it('sets aria-invalid when error is present', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('sets aria-invalid=false when no error', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'false'); + }); + }); + + describe('NumberInput', () => { + it('sets aria-describedby pointing to error element', () => { + render(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('aria-describedby', 'count-error'); + expect(screen.getByText('Must be positive')).toHaveAttribute('id', 'count-error'); + }); + + it('sets aria-describedby pointing to helper text', () => { + render(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('aria-describedby', 'count-helper'); + }); + }); + + describe('Textarea', () => { + it('sets aria-describedby pointing to error element', () => { + render(