From 099c850702888c10197345e118f8b270b63c90b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 09:29:42 +0200 Subject: [PATCH 01/13] chore(rgaa): update grays for better contrast --- src/main.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.css b/src/main.css index 752f4e1..a23cd6d 100644 --- a/src/main.css +++ b/src/main.css @@ -17,8 +17,8 @@ --alma-red: #CF2020; --soft-red: #ffecec; --off-white: #f9f9f9; - --light-gray: #F0F0F0; - --dark-gray: #6C6C6C; + --light-gray: #DCDCDC; /* Color recommended for better contrast based on accessibility requirements */ + --dark-gray: #4A4A4A; /* Color recommended for better contrast based on accessibility requirements */ --off-black: #1a1a1a; --white: #fff; --black: #000; From e6ef57e4355e8c087d31e65e9ef1a5afda4218c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 10:08:50 +0200 Subject: [PATCH 02/13] chore(rgaa): set minimum transitionDelay to 1sec --- .../PaymentPlans/__tests__/CustomTransitionDelay.test.tsx | 4 +++- src/hooks/useButtonAnimation.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Widgets/PaymentPlans/__tests__/CustomTransitionDelay.test.tsx b/src/Widgets/PaymentPlans/__tests__/CustomTransitionDelay.test.tsx index 813c259..db55e60 100644 --- a/src/Widgets/PaymentPlans/__tests__/CustomTransitionDelay.test.tsx +++ b/src/Widgets/PaymentPlans/__tests__/CustomTransitionDelay.test.tsx @@ -12,7 +12,9 @@ jest.mock('utils/fetch', () => ({ })) jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime()) -const animationDuration = 500 +// Set a custom animation duration > 1000ms to test the transition delay prop +// The minimum transition delay in useButtonAnimation is 1000ms +const animationDuration = 1200 describe('Custom transition delay', () => { const setup = async () => { diff --git a/src/hooks/useButtonAnimation.ts b/src/hooks/useButtonAnimation.ts index 0b41b28..20aa671 100644 --- a/src/hooks/useButtonAnimation.ts +++ b/src/hooks/useButtonAnimation.ts @@ -16,8 +16,8 @@ const useButtonAnimation = (iterateValues: number[], transitionDelay: number): P window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches - // Cap transition delay to 5 seconds max for WCAG compliance - const cappedTransitionDelay = Math.min(transitionDelay, 5000) + // Set minimum transition delay to 1 second, or -1 to disable animation + const cappedTransitionDelay = transitionDelay < 0 ? -1 : Math.max(transitionDelay, 1000) useEffect(() => { let timeout: ReturnType From 7e657fc4949e20ef7abed6c48bae883863aa10f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 11:15:19 +0200 Subject: [PATCH 03/13] chore(rgaa): fix useEffect that triggers re-renders loop + add comments --- src/Widgets/PaymentPlans/index.tsx | 142 ++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 42 deletions(-) diff --git a/src/Widgets/PaymentPlans/index.tsx b/src/Widgets/PaymentPlans/index.tsx index 886e9ad..fb05cbf 100644 --- a/src/Widgets/PaymentPlans/index.tsx +++ b/src/Widgets/PaymentPlans/index.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useEffect, useRef, useState } from 'react' +import React, { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react' import cx from 'classnames' import { useIntl } from 'react-intl' @@ -37,6 +37,15 @@ type Props = { const VERY_LONG_TIME_IN_MS = 1000 * 3600 const DEFAULT_TRANSITION_TIME = 5500 +/** + * PaymentPlanWidget - Main widget component that displays Alma payment plan options + * + * This component shows eligible payment plans in a compact widget format with: + * - Automatic plan cycling animation + * - Keyboard navigation between plans + * - Screen reader announcements for accessibility + * - Modal opening for detailed plan information + */ const PaymentPlanWidget: FunctionComponent = ({ apiData, configPlans, @@ -52,6 +61,8 @@ const PaymentPlanWidget: FunctionComponent = ({ onModalClose, }) => { const intl = useIntl() + + // Fetch eligibility data for all payment plans const [eligibilityPlans, status] = useFetchEligibility( purchaseAmount, apiData, @@ -59,13 +70,25 @@ const PaymentPlanWidget: FunctionComponent = ({ customerBillingCountry, customerShippingCountry, ) - const eligiblePlans = eligibilityPlans.filter((plan) => plan.eligible) + + // Memoized array of only eligible plans to avoid unnecessary re-renders + const eligiblePlans = useMemo( + () => eligibilityPlans.filter((plan) => plan.eligible), + [eligibilityPlans], + ) + + // Determine which plan should be active initially based on merchant preferences const activePlanIndex = getIndexOfActivePlan({ eligibilityPlans, suggestedPaymentPlan: suggestedPaymentPlan ?? 0, }) + + // Check if merchant has specified a suggested payment plan const isSuggestedPaymentPlanSpecified = suggestedPaymentPlan !== undefined // 👈 The merchant decided to focus a tab + // Check if merchant has specified a custom transition delay const isTransitionSpecified = transitionDelay !== undefined // 👈 The merchant has specified a transition time + + // Modal state management const [isOpen, setIsOpen] = useState(false) const { announceText, announce } = useAnnounceText() const openModal = () => setIsOpen(true) @@ -74,18 +97,24 @@ const PaymentPlanWidget: FunctionComponent = ({ onModalClose?.(event) } - const eligiblePlanKeys = eligibilityPlans.reduce( - (acc, plan, index) => (plan.eligible ? [...acc, index] : acc), - [], + // Memoized array of eligible plan indices for keyboard navigation + const eligiblePlanKeys = useMemo( + () => + eligibilityPlans.reduce( + (acc, plan, index) => (plan.eligible ? [...acc, index] : acc), + [], + ), + [eligibilityPlans], ) /** + * Calculate the appropriate transition time based on merchant configuration * If merchant specify a suggestedPaymentPlan and no transition, we set a very long transition delay. * Otherwise, we set the transition delay specified by the merchant. * If none of those properties are specified, we set a default transition delay. * @returns {number} The transition time in milliseconds */ - const realTransitionTime = (): number => { + const realTransitionTime = useMemo((): number => { if (isTransitionSpecified) { return transitionDelay ?? DEFAULT_TRANSITION_TIME } @@ -93,20 +122,55 @@ const PaymentPlanWidget: FunctionComponent = ({ return VERY_LONG_TIME_IN_MS } return DEFAULT_TRANSITION_TIME - } + }, [isTransitionSpecified, transitionDelay, isSuggestedPaymentPlanSpecified]) - const { current, onHover, onLeave } = useButtonAnimation(eligiblePlanKeys, realTransitionTime()) + // Hook for managing plan cycling animation and user interactions + const { current, onHover, onLeave } = useButtonAnimation(eligiblePlanKeys, realTransitionTime) - // Refs for managing focus on plan buttons + // Refs for managing focus on plan buttons during keyboard navigation const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]) - // Initialize button refs array + // Initialize button refs array when eligibility plans change useEffect(() => { buttonRefs.current = buttonRefs.current.slice(0, eligibilityPlans.length) }, [eligibilityPlans.length]) + // Announce plan changes to screen readers for accessibility + useEffect(() => { + if (eligibilityPlans[current] && status === statusResponse.SUCCESS) { + const currentPlan = eligibilityPlans[current] + + const planDescription = getPlanDescription(currentPlan, intl) + const announcementText = intl.formatMessage( + { + id: 'accessibility.plan-selection-changed', + defaultMessage: 'Plan sélectionné : {planDescription}', + }, + { planDescription }, + ) + + announce(announcementText, 1000) + } + // Note: eligibilityPlans is intentionally excluded from dependencies to prevent render loops + // since it's recreated on every render by useFetchEligibility + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [current, intl, status, announce]) + + // Set initial active plan when API response is received and merchant has specified a suggestion + useEffect(() => { + // When API has given a response AND the merchant set an active plan by default. + if (status === statusResponse.SUCCESS && isSuggestedPaymentPlanSpecified) { + onHover(activePlanIndex) // We select the first active plan possible + onLeave() // We need to call onLeave to reset the animation + } + // We intentionally exclude 'activePlanIndex', 'isSuggestedPaymentPlanSpecified', 'onHover', and 'onLeave' + // because including them would cause the effect to re-run unnecessarily, leading to unwanted behavior. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]) + /** * Navigate to the next or previous eligible plan and focus the corresponding button + * Used for arrow key navigation between payment plans * @param direction - 'next' or 'prev' for navigation direction * @param currentIndex - Current plan index */ @@ -127,12 +191,13 @@ const PaymentPlanWidget: FunctionComponent = ({ const newPlanIndex = eligiblePlanKeys[newEligibleIndex] onHover(newPlanIndex) - // Focus the new button + // Focus the new button for keyboard users buttonRefs.current[newPlanIndex]?.focus() } /** * Navigate to first or last eligible plan + * Used for Home/End key navigation * @param position - 'first' or 'last' */ const navigateToEdgePlan = (position: 'first' | 'last') => { @@ -142,37 +207,9 @@ const PaymentPlanWidget: FunctionComponent = ({ buttonRefs.current[planIndex]?.focus() } - // Announce plan changes to screen readers - useEffect(() => { - if (eligibilityPlans[current] && status === statusResponse.SUCCESS) { - const currentPlan = eligibilityPlans[current] - - const planDescription = getPlanDescription(currentPlan, intl) - const announcementText = intl.formatMessage( - { - id: 'accessibility.plan-selection-changed', - defaultMessage: 'Plan sélectionné : {planDescription}', - }, - { planDescription }, - ) - - announce(announcementText, 1000) - } - }, [current, eligibilityPlans, intl, status, announce]) - - useEffect(() => { - // When API has given a response AND the marchand set an active plan by default. - if (status === statusResponse.SUCCESS && isSuggestedPaymentPlanSpecified) { - onHover(activePlanIndex) // We select the first active plan possible - onLeave() // We need to call onLeave to reset the animation - } - // We intentionally exclude 'activePlanIndex', 'isSuggestedPaymentPlanSpecified', 'onHover', and 'onLeave' - // because including them would cause the effect to re-run unnecessarily, leading to unwanted behavior. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status]) - /** - * It takes a plan index and returns the index of that plan within the eligible plans + * Convert a plan index to its position within the eligible plans array + * Used for modal initialization to show the correct plan * * @param {number} planIndex - The index of the plan that the user has selected. * @returns The index of the planKey in the eligiblePlanKeys array. @@ -182,6 +219,7 @@ const PaymentPlanWidget: FunctionComponent = ({ return index === -1 ? 0 : index } + // Show loading state while fetching eligibility data if (status === statusResponse.PENDING) { return (
@@ -190,6 +228,7 @@ const PaymentPlanWidget: FunctionComponent = ({ ) } + // Hide widget if no eligible plans and merchant wants to hide it, or if API failed if ( (hideIfNotEligible && eligiblePlans.length === 0) || eligibilityPlans.length === 0 || @@ -198,6 +237,10 @@ const PaymentPlanWidget: FunctionComponent = ({ return null } + /** + * Handle opening the eligibility modal + * Prevents default behavior and only opens if there are eligible plans + */ const handleOpenModal = ( e: React.MouseEvent | React.KeyboardEvent, ) => { @@ -209,6 +252,7 @@ const PaymentPlanWidget: FunctionComponent = ({ return ( <> + {/* Main widget container - clickable to open modal */}
{ @@ -235,8 +279,11 @@ const PaymentPlanWidget: FunctionComponent = ({ defaultMessage: 'Ouvrir les options de paiement Alma', })} > + {/* Primary content container with logo and payment plans */}
+ + {/* Payment plans radio group for keyboard navigation */}
= ({ @@ -318,6 +370,8 @@ const PaymentPlanWidget: FunctionComponent = ({ })}
+ + {/* Payment plan information text */}
= ({ {eligibilityPlans.length !== 0 && paymentPlanInfoText(eligibilityPlans[current])}
+ + {/* Eligibility modal for detailed plan information */} {isOpen && ( = ({ cards={cards} /> )} + + {/* Screen reader announcement area for accessibility */}
{announceText}
From f597e9a107e2c95c78cac2a01b1f201257d85dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 11:41:12 +0200 Subject: [PATCH 04/13] chore(rgaa): stop automated animation on user's interaction --- examples/basic.html | 1 + .../__tests__/Accessibility.test.tsx | 293 +++++++++++++++++- src/Widgets/PaymentPlans/index.tsx | 73 ++++- src/intl/messages.json | 2 + src/intl/messages/messages.de.json | 2 + src/intl/messages/messages.en.json | 2 + src/intl/messages/messages.es.json | 2 + src/intl/messages/messages.fr.json | 2 + src/intl/messages/messages.it.json | 2 + src/intl/messages/messages.nl.json | 2 + src/intl/messages/messages.pt.json | 2 + 11 files changed, 364 insertions(+), 19 deletions(-) diff --git a/examples/basic.html b/examples/basic.html index bc8599c..4c03100 100644 --- a/examples/basic.html +++ b/examples/basic.html @@ -59,6 +59,7 @@

450 €

container: '#alma-widget-payment-plans', locale: 'fr', purchaseAmount, + transitionDelay: 1200, }) } renderPaymentPlans() diff --git a/src/Widgets/PaymentPlans/__tests__/Accessibility.test.tsx b/src/Widgets/PaymentPlans/__tests__/Accessibility.test.tsx index 6899ffc..c54dc90 100644 --- a/src/Widgets/PaymentPlans/__tests__/Accessibility.test.tsx +++ b/src/Widgets/PaymentPlans/__tests__/Accessibility.test.tsx @@ -24,7 +24,23 @@ const mockUseFetchEligibility = require('hooks/useFetchEligibility').default as typeof import('hooks/useFetchEligibility').default > +// Mock useAnnounceText hook globally - but we'll override it for specific tests +jest.mock('hooks/useAnnounceText', () => { + const actual = jest.requireActual('hooks/useAnnounceText') + return { + ...actual, + useAnnounceText: jest.fn(), + } +}) +// eslint-disable-next-line global-require +const mockUseAnnounceText = require('hooks/useAnnounceText').useAnnounceText as jest.MockedFunction< + typeof import('hooks/useAnnounceText').useAnnounceText +> + describe('PaymentPlan Accessibility Tests', () => { + // Mock useAnnounceText hook to test announcements + const mockAnnounce = jest.fn() + const mockClearAnnouncement = jest.fn() beforeEach(() => { jest.clearAllMocks() // Reset to default mock behavior @@ -33,6 +49,13 @@ describe('PaymentPlan Accessibility Tests', () => { // Mock requestAnimationFrame to avoid timing issues in tests global.requestAnimationFrame = jest.fn((cb) => setTimeout(cb, 0)) global.cancelAnimationFrame = jest.fn() + + // Configure the global mock for this test suite + mockUseAnnounceText.mockReturnValue({ + announceText: '', + announce: mockAnnounce, + clearAnnouncement: mockClearAnnouncement, + }) }) afterEach(() => { @@ -142,6 +165,21 @@ describe('PaymentPlan Accessibility Tests', () => { // ======================== describe('AnnounceText functionality', () => { + beforeEach(() => { + // For these tests, restore the real implementation + const realHook = jest.requireActual('hooks/useAnnounceText') + mockUseAnnounceText.mockImplementation(realHook.useAnnounceText) + }) + + afterEach(() => { + // Restore the mock for other test suites + mockUseAnnounceText.mockReturnValue({ + announceText: '', + announce: mockAnnounce, + clearAnnouncement: mockClearAnnouncement, + }) + }) + it('should have an alert region for screen reader announcements', async () => { mockUseFetchEligibility.mockReturnValue([ [{ ...mockButtonPlans[0], eligible: true, installments_count: 3 }], @@ -468,7 +506,9 @@ describe('PaymentPlan Accessibility Tests', () => { if (paymentButtons.length > 0) { // Focus on an eligible plan button should trigger onHover - paymentButtons[0].focus() + await act(async () => { + paymentButtons[0].focus() + }) // The onHover functionality should work without errors // We can verify this by checking the button is still accessible and focusable @@ -527,8 +567,10 @@ describe('PaymentPlan Accessibility Tests', () => { writable: false, }) - secondButton.focus() - secondButton.dispatchEvent(arrowLeftEvent) + await act(async () => { + secondButton.focus() + secondButton.dispatchEvent(arrowLeftEvent) + }) expect(preventDefaultSpy).toHaveBeenCalled() } @@ -560,8 +602,10 @@ describe('PaymentPlan Accessibility Tests', () => { writable: false, }) - firstButton.focus() - firstButton.dispatchEvent(arrowRightEvent) + await act(async () => { + firstButton.focus() + firstButton.dispatchEvent(arrowRightEvent) + }) expect(preventDefaultSpy).toHaveBeenCalled() } @@ -593,8 +637,10 @@ describe('PaymentPlan Accessibility Tests', () => { writable: false, }) - button.focus() - button.dispatchEvent(homeEvent) + await act(async () => { + button.focus() + button.dispatchEvent(homeEvent) + }) expect(preventDefaultSpy).toHaveBeenCalled() } @@ -626,8 +672,10 @@ describe('PaymentPlan Accessibility Tests', () => { writable: false, }) - button.focus() - button.dispatchEvent(endEvent) + await act(async () => { + button.focus() + button.dispatchEvent(endEvent) + }) expect(preventDefaultSpy).toHaveBeenCalled() } @@ -652,7 +700,9 @@ describe('PaymentPlan Accessibility Tests', () => { if (paymentButtons.length > 1) { // Focus on second button and press ArrowLeft const secondButton = paymentButtons[1] - secondButton.focus() + await act(async () => { + secondButton.focus() + }) // Simulate ArrowLeft - should navigate to previous eligible plan await act(async () => { @@ -694,7 +744,9 @@ describe('PaymentPlan Accessibility Tests', () => { if (paymentButtons.length > 1) { // Focus on first button (1x) and press ArrowRight const firstButton = paymentButtons[0] - firstButton.focus() + await act(async () => { + firstButton.focus() + }) // Simulate ArrowRight - should navigate to next eligible plan (3x, skipping 2x) await act(async () => { @@ -724,7 +776,9 @@ describe('PaymentPlan Accessibility Tests', () => { if (paymentButtons.length > 1) { // Focus on last button and press Home const lastButton = paymentButtons[paymentButtons.length - 1] - lastButton.focus() + await act(async () => { + lastButton.focus() + }) // Simulate Home key - should navigate to first eligible plan await act(async () => { @@ -754,7 +808,9 @@ describe('PaymentPlan Accessibility Tests', () => { if (paymentButtons.length > 1) { // Focus on first button and press End const firstButton = paymentButtons[0] - firstButton.focus() + await act(async () => { + firstButton.focus() + }) // Simulate End key - should navigate to last eligible plan await act(async () => { @@ -767,4 +823,215 @@ describe('PaymentPlan Accessibility Tests', () => { } }) }) + + // ======================== + // Animation Control Tests + // ======================== + + describe('Animation Control Tests', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + describe('Animation Stop on User Interaction', () => { + it('should announce animation control instructions when animation is active', async () => { + render( + , + ) + + await screen.findByTestId('widget-button') + + // Fast-forward past the announcement delay (1500ms) + act(() => { + jest.advanceTimersByTime(2000) + }) + + // Check that animation control instructions were announced + expect(mockAnnounce).toHaveBeenCalledWith( + expect.stringContaining('Animation automatique'), + 2000, + ) + }) + + it('should not announce animation control instructions when animation is disabled', async () => { + render( + , + ) + + await screen.findByTestId('widget-button') + + // Fast-forward past the announcement delay + act(() => { + jest.advanceTimersByTime(2000) + }) + + // Check that animation control instructions were NOT announced + expect(mockAnnounce).not.toHaveBeenCalledWith( + expect.stringContaining('Animation automatique'), + expect.any(Number), + ) + }) + + it('should include animation control description in aria-description when animation is active', async () => { + render( + , + ) + + const widget = await screen.findByTestId('widget-button') + + expect(widget).toHaveAttribute( + 'aria-description', + expect.stringContaining('Animation automatique active'), + ) + }) + + it('should not include animation control description when animation is disabled', async () => { + render( + , + ) + + const widget = await screen.findByTestId('widget-button') + + expect(widget).not.toHaveAttribute('aria-description') + }) + + it('should stop animation and remove aria-description after hover interaction', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + + render( + , + ) + + const widget = await screen.findByTestId('widget-button') + const firstPlan = screen.getAllByRole('radio')[0] + + // Initially should have aria-description + expect(widget).toHaveAttribute('aria-description') + + // Hover over a plan (user interaction) + await act(async () => { + await user.hover(firstPlan) + }) + + // Should no longer have aria-description after interaction + expect(widget).not.toHaveAttribute('aria-description') + }) + + it('should stop animation after keyboard navigation', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + + render( + , + ) + + const widget = await screen.findByTestId('widget-button') + const plans = screen.getAllByRole('radio') + const eligiblePlans = plans.filter((plan) => plan.getAttribute('aria-disabled') !== 'true') + + // Initially should have aria-description (animation active) + expect(widget).toHaveAttribute('aria-description') + + // Focus first plan and navigate with arrow key + act(() => { + eligiblePlans[0].focus() + }) + await act(async () => { + await user.keyboard('{ArrowRight}') + }) + + // Should remove aria-description after keyboard interaction + expect(widget).not.toHaveAttribute('aria-description') + }) + + it('should support arrow key navigation between eligible plans', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + + render( + , + ) + + const plans = screen.getAllByRole('radio') + const eligiblePlans = plans.filter((plan) => plan.getAttribute('aria-disabled') !== 'true') + + // Focus first eligible plan + act(() => { + eligiblePlans[0].focus() + }) + + // Navigate to next plan with arrow key + await act(async () => { + await user.keyboard('{ArrowRight}') + }) + + // Should focus the next eligible plan + expect(eligiblePlans[1]).toHaveFocus() + }) + + it('should support Home/End key navigation', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + + render( + , + ) + + const plans = screen.getAllByRole('radio') + const eligiblePlans = plans.filter((plan) => plan.getAttribute('aria-disabled') !== 'true') + + // Focus middle plan + if (eligiblePlans.length > 1) { + act(() => { + eligiblePlans[1].focus() + }) + + // Navigate to first plan with Home key + await act(async () => { + await user.keyboard('{Home}') + }) + + expect(eligiblePlans[0]).toHaveFocus() + + // Navigate to last plan with End key + await act(async () => { + await user.keyboard('{End}') + }) + expect(eligiblePlans[eligiblePlans.length - 1]).toHaveFocus() + } + }) + }) + }) }) diff --git a/src/Widgets/PaymentPlans/index.tsx b/src/Widgets/PaymentPlans/index.tsx index fb05cbf..a3ccc70 100644 --- a/src/Widgets/PaymentPlans/index.tsx +++ b/src/Widgets/PaymentPlans/index.tsx @@ -97,6 +97,9 @@ const PaymentPlanWidget: FunctionComponent = ({ onModalClose?.(event) } + // Track if user has manually interacted with plans to stop automatic animation + const [hasUserInteracted, setHasUserInteracted] = useState(false) + // Memoized array of eligible plan indices for keyboard navigation const eligiblePlanKeys = useMemo( () => @@ -168,6 +171,51 @@ const PaymentPlanWidget: FunctionComponent = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]) + // Announce animation control instructions to screen readers on initial load + useEffect(() => { + if ( + status === statusResponse.SUCCESS && + !hasUserInteracted && + eligiblePlans.length > 1 && + // If transitionDelay is -1, animation is disabled, so no need to announce instructions + transitionDelay !== -1 + ) { + const instructionText = intl.formatMessage({ + id: 'accessibility.animation-control-instructions', + defaultMessage: + "Animation automatique des plans de paiement active. Survolez ou naviguez avec les flèches pour arrêter l'animation.", + }) + + // Delay the announcement to avoid conflict with initial plan announcements + const timer = setTimeout(() => { + announce(instructionText, 2000) + }, 1500) + + return () => clearTimeout(timer) + } + + // Return undefined if condition is not met (no cleanup needed) + return undefined + }, [status, hasUserInteracted, eligiblePlans.length, intl, announce, transitionDelay]) + + /** + * Handle user hover interaction - stops animation permanently + * @param index - Plan index to hover + */ + const handleUserHover = (index: number) => { + setHasUserInteracted(true) + onHover(index) + } + + /** + * Handle user leave interaction - only restart animation if user hasn't interacted manually + */ + const handleUserLeave = () => { + if (!hasUserInteracted) { + onLeave() + } + } + /** * Navigate to the next or previous eligible plan and focus the corresponding button * Used for arrow key navigation between payment plans @@ -189,6 +237,8 @@ const PaymentPlanWidget: FunctionComponent = ({ } const newPlanIndex = eligiblePlanKeys[newEligibleIndex] + // Mark as user interaction to stop animation permanently + setHasUserInteracted(true) onHover(newPlanIndex) // Focus the new button for keyboard users @@ -203,6 +253,8 @@ const PaymentPlanWidget: FunctionComponent = ({ const navigateToEdgePlan = (position: 'first' | 'last') => { const planIndex = position === 'first' ? eligiblePlanKeys[0] : eligiblePlanKeys[eligiblePlanKeys.length - 1] + // Mark as user interaction to stop animation permanently + setHasUserInteracted(true) onHover(planIndex) buttonRefs.current[planIndex]?.focus() } @@ -278,6 +330,15 @@ const PaymentPlanWidget: FunctionComponent = ({ id: 'accessibility.payment-widget.open-button.aria-label', defaultMessage: 'Ouvrir les options de paiement Alma', })} + aria-description={ + eligiblePlans.length > 1 && !hasUserInteracted && transitionDelay !== -1 + ? intl.formatMessage({ + id: 'accessibility.payment-widget.animation-control-description', + defaultMessage: + "Animation automatique active. Survolez ou utilisez les flèches pour arrêter l'animation.", + }) + : undefined + } > {/* Primary content container with logo and payment plans */}
@@ -301,13 +362,13 @@ const PaymentPlanWidget: FunctionComponent = ({ type="button" key={`p${eligibilityPlan.installments_count}x-d+${eligibilityPlan.deferred_days}-m+${eligibilityPlan.deferred_months}`} // Mouse/touch interactions for plan selection - onMouseEnter={() => onHover(key)} - onTouchStart={() => onHover(key)} - onMouseLeave={onLeave} - onBlur={onLeave} - onTouchEnd={onLeave} + onMouseEnter={() => handleUserHover(key)} + onTouchStart={() => handleUserHover(key)} + onMouseLeave={handleUserLeave} + onBlur={handleUserLeave} + onTouchEnd={handleUserLeave} // Focus handling for keyboard users - onFocus={isEligible ? () => onHover(key) : undefined} + onFocus={isEligible ? () => handleUserHover(key) : undefined} // Keyboard navigation between eligible plans onKeyDown={(e) => { if (!isEligible) return diff --git a/src/intl/messages.json b/src/intl/messages.json index 177629f..7347291 100644 --- a/src/intl/messages.json +++ b/src/intl/messages.json @@ -1,6 +1,7 @@ { "accessibility.alma-logo.aria-label": "Logo Alma - Solution de paiement en plusieurs fois", "accessibility.amex-card.aria-label": "Carte American Express acceptée", + "accessibility.animation-control-instructions": "Animation automatique des plans de paiement active. Survolez ou naviguez avec les flèches pour arrêter l'animation.", "accessibility.cb-card.aria-label": "Carte Bancaire CB acceptée", "accessibility.close-button.aria-label": "Fermer la fenêtre", "accessibility.mastercard.aria-label": "Carte Mastercard acceptée", @@ -9,6 +10,7 @@ "accessibility.payment-plan.option.aria-label": "Option de paiement {planDescription}", "accessibility.payment-plans-title": "Options de paiement disponibles", "accessibility.payment-schedule-title": "Calendrier de paiement", + "accessibility.payment-widget.animation-control-description": "Animation automatique active. Survolez ou utilisez les flèches pour arrêter l'animation.", "accessibility.payment-widget.open-button.aria-label": "Ouvrir les options de paiement Alma", "accessibility.plan-selection-changed": "Plan sélectionné : {planDescription}", "accessibility.skip-links.navigation.aria-label": "Navigation rapide", diff --git a/src/intl/messages/messages.de.json b/src/intl/messages/messages.de.json index 0d4427f..e6e5ecb 100644 --- a/src/intl/messages/messages.de.json +++ b/src/intl/messages/messages.de.json @@ -1,6 +1,7 @@ { "accessibility.alma-logo.aria-label": "Alma-Logo - Lösung für Ratenzahlung", "accessibility.amex-card.aria-label": "American Express-Karte akzeptiert", + "accessibility.animation-control-instructions": "Automatische Animation der Zahlungspläne aktiv. Bewegen Sie den Mauszeiger über oder navigieren Sie mit den Pfeiltasten, um die Animation zu stoppen.", "accessibility.cb-card.aria-label": "Kreditkarte CB akzeptiert", "accessibility.close-button.aria-label": "Fenster schließen", "accessibility.mastercard.aria-label": "Mastercard akzeptiert", @@ -9,6 +10,7 @@ "accessibility.payment-plan.option.aria-label": "Zahlungsoption {planDescription}", "accessibility.payment-plans-title": "Verfügbare Zahlungsoptionen", "accessibility.payment-schedule-title": "Ratenplan für die Zahlung", + "accessibility.payment-widget.animation-control-description": "Automatische Animation ist aktiv. Bewegen Sie den Mauszeiger darüber oder verwenden Sie die Pfeile, um die Animation zu stoppen.", "accessibility.payment-widget.open-button.aria-label": "Alma Zahlungsoptionen öffnen", "accessibility.plan-selection-changed": "Ausgewählter Plan: {planDescription}", "accessibility.skip-links.navigation.aria-label": "Schnelle Navigation", diff --git a/src/intl/messages/messages.en.json b/src/intl/messages/messages.en.json index b663629..3ed4221 100644 --- a/src/intl/messages/messages.en.json +++ b/src/intl/messages/messages.en.json @@ -1,6 +1,7 @@ { "accessibility.alma-logo.aria-label": "Alma logo - Multi-installment payment solution", "accessibility.amex-card.aria-label": "American Express card accepted", + "accessibility.animation-control-instructions": "Automatic animation of payment plans active. Hover or navigate with the arrows to stop animation.", "accessibility.cb-card.aria-label": "CB credit card accepted", "accessibility.close-button.aria-label": "Close window", "accessibility.mastercard.aria-label": "Mastercard accepted", @@ -9,6 +10,7 @@ "accessibility.payment-plan.option.aria-label": "Payment option {planDescription}", "accessibility.payment-plans-title": "Available payment options", "accessibility.payment-schedule-title": "Payment schedule", + "accessibility.payment-widget.animation-control-description": "Animation automatique active. Survolez ou utilisez les flèches pour arrêter l'animation.", "accessibility.payment-widget.open-button.aria-label": "Open Alma payment options", "accessibility.plan-selection-changed": "Selected plan: {planDescription}", "accessibility.skip-links.navigation.aria-label": "Quick navigation", diff --git a/src/intl/messages/messages.es.json b/src/intl/messages/messages.es.json index 6d2e65f..73f6923 100644 --- a/src/intl/messages/messages.es.json +++ b/src/intl/messages/messages.es.json @@ -1,6 +1,7 @@ { "accessibility.alma-logo.aria-label": "Logotipo Alma - Solución de pago multipago", "accessibility.amex-card.aria-label": "Se acepta la tarjeta American Express", + "accessibility.animation-control-instructions": "La animación automática de los planes de pago está activa. Pase el ratón por encima o desplácese con las flechas para detener la animación.", "accessibility.cb-card.aria-label": "Se acepta tarjeta de crédito CB", "accessibility.close-button.aria-label": "Cerrar ventana", "accessibility.mastercard.aria-label": "Se acepta Mastercard", @@ -9,6 +10,7 @@ "accessibility.payment-plan.option.aria-label": "Forma de pago {planDescription}", "accessibility.payment-plans-title": "Formas de pago disponibles", "accessibility.payment-schedule-title": "Calendario de pagos", + "accessibility.payment-widget.animation-control-description": "Animación automática activa. Pase el ratón por encima o utilice las flechas para detener la animación.", "accessibility.payment-widget.open-button.aria-label": "Opciones de pago Open Alma", "accessibility.plan-selection-changed": "Plan seleccionado: {planDescription}", "accessibility.skip-links.navigation.aria-label": "Navegación rápida", diff --git a/src/intl/messages/messages.fr.json b/src/intl/messages/messages.fr.json index 177629f..7347291 100644 --- a/src/intl/messages/messages.fr.json +++ b/src/intl/messages/messages.fr.json @@ -1,6 +1,7 @@ { "accessibility.alma-logo.aria-label": "Logo Alma - Solution de paiement en plusieurs fois", "accessibility.amex-card.aria-label": "Carte American Express acceptée", + "accessibility.animation-control-instructions": "Animation automatique des plans de paiement active. Survolez ou naviguez avec les flèches pour arrêter l'animation.", "accessibility.cb-card.aria-label": "Carte Bancaire CB acceptée", "accessibility.close-button.aria-label": "Fermer la fenêtre", "accessibility.mastercard.aria-label": "Carte Mastercard acceptée", @@ -9,6 +10,7 @@ "accessibility.payment-plan.option.aria-label": "Option de paiement {planDescription}", "accessibility.payment-plans-title": "Options de paiement disponibles", "accessibility.payment-schedule-title": "Calendrier de paiement", + "accessibility.payment-widget.animation-control-description": "Animation automatique active. Survolez ou utilisez les flèches pour arrêter l'animation.", "accessibility.payment-widget.open-button.aria-label": "Ouvrir les options de paiement Alma", "accessibility.plan-selection-changed": "Plan sélectionné : {planDescription}", "accessibility.skip-links.navigation.aria-label": "Navigation rapide", diff --git a/src/intl/messages/messages.it.json b/src/intl/messages/messages.it.json index 936a8fd..82947c1 100644 --- a/src/intl/messages/messages.it.json +++ b/src/intl/messages/messages.it.json @@ -1,6 +1,7 @@ { "accessibility.alma-logo.aria-label": "Logo Alma - Soluzione di pagamento multi-pagamento", "accessibility.amex-card.aria-label": "Carta American Express accettata", + "accessibility.animation-control-instructions": "L'animazione automatica dei piani di pagamento è attiva. Passare il mouse sopra o navigare con le frecce per fermare l'animazione.", "accessibility.cb-card.aria-label": "Carta di credito CB accettata", "accessibility.close-button.aria-label": "Chiudere la finestra", "accessibility.mastercard.aria-label": "Mastercard accettata", @@ -9,6 +10,7 @@ "accessibility.payment-plan.option.aria-label": "Opzione di pagamento {planDescription}", "accessibility.payment-plans-title": "Opzioni di pagamento disponibili", "accessibility.payment-schedule-title": "Piano di pagamento", + "accessibility.payment-widget.animation-control-description": "Animazione automatica attiva. Passare il mouse sopra o usare le frecce per fermare l'animazione.", "accessibility.payment-widget.open-button.aria-label": "Opzioni di pagamento Alma aperte", "accessibility.plan-selection-changed": "Piano selezionato: {planDescription}", "accessibility.skip-links.navigation.aria-label": "Navigazione rapida", diff --git a/src/intl/messages/messages.nl.json b/src/intl/messages/messages.nl.json index 4e9f4a3..6d1a888 100644 --- a/src/intl/messages/messages.nl.json +++ b/src/intl/messages/messages.nl.json @@ -1,6 +1,7 @@ { "accessibility.alma-logo.aria-label": "Alma-logo - Multi-betalingoplossing", "accessibility.amex-card.aria-label": "American Express kaart geaccepteerd", + "accessibility.animation-control-instructions": "Automatische animatie van betalingsplannen is actief. Beweeg erover of navigeer met de pijltjes om de animatie te stoppen.", "accessibility.cb-card.aria-label": "CB creditcard geaccepteerd", "accessibility.close-button.aria-label": "Venster sluiten", "accessibility.mastercard.aria-label": "Mastercard geaccepteerd", @@ -9,6 +10,7 @@ "accessibility.payment-plan.option.aria-label": "Betalingsoptie {planDescription}", "accessibility.payment-plans-title": "Betalingsopties beschikbaar", "accessibility.payment-schedule-title": "Betalingsschema", + "accessibility.payment-widget.animation-control-description": "Automatische animatie actief. Beweeg erover of gebruik de pijltjes om de animatie te stoppen.", "accessibility.payment-widget.open-button.aria-label": "Alma betalingsopties openen", "accessibility.plan-selection-changed": "Gekozen plan: {planDescription}", "accessibility.skip-links.navigation.aria-label": "Snelle navigatie", diff --git a/src/intl/messages/messages.pt.json b/src/intl/messages/messages.pt.json index bbfd2b1..65ebb13 100644 --- a/src/intl/messages/messages.pt.json +++ b/src/intl/messages/messages.pt.json @@ -1,6 +1,7 @@ { "accessibility.alma-logo.aria-label": "Logótipo Alma - Solução de pagamento multi-instalação", "accessibility.amex-card.aria-label": "Aceita cartão American Express", + "accessibility.animation-control-instructions": "A animação automática dos planos de pagamento está ativa. Passe o rato por cima ou navegue com as setas para parar a animação.", "accessibility.cb-card.aria-label": "Aceita-se cartão de crédito CB", "accessibility.close-button.aria-label": "Fechar a janela", "accessibility.mastercard.aria-label": "Aceita Mastercard", @@ -9,6 +10,7 @@ "accessibility.payment-plan.option.aria-label": "Opção de pagamento {planDescription}", "accessibility.payment-plans-title": "Opções de pagamento disponíveis", "accessibility.payment-schedule-title": "Calendário de pagamento", + "accessibility.payment-widget.animation-control-description": "Animação automática ativa. Passe o rato por cima ou utilize as setas para parar a animação.", "accessibility.payment-widget.open-button.aria-label": "Opções de pagamento da Open Alma", "accessibility.plan-selection-changed": "Plano selecionado: {planDescription}", "accessibility.skip-links.navigation.aria-label": "Navegação rápida", From 5d705e0f685459844acf8192382abde1971cb45c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 13:52:14 +0200 Subject: [PATCH 05/13] chore(rgaa): stop animation after going through a full cycle --- examples/basic.html | 3 +- src/hooks/useButtonAnimation.test.ts | 349 +++++++++++++++++++++++++++ src/hooks/useButtonAnimation.ts | 20 +- 3 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useButtonAnimation.test.ts diff --git a/examples/basic.html b/examples/basic.html index 4c03100..b1746d6 100644 --- a/examples/basic.html +++ b/examples/basic.html @@ -59,7 +59,8 @@

450 €

container: '#alma-widget-payment-plans', locale: 'fr', purchaseAmount, - transitionDelay: 1200, + // transitionDelay: 1200, + suggestedPlan: [2, 3], }) } renderPaymentPlans() diff --git a/src/hooks/useButtonAnimation.test.ts b/src/hooks/useButtonAnimation.test.ts new file mode 100644 index 0000000..72f77ba --- /dev/null +++ b/src/hooks/useButtonAnimation.test.ts @@ -0,0 +1,349 @@ +import { act, renderHook } from '@testing-library/react' + +import useButtonAnimation from 'hooks/useButtonAnimation' + +// Mock matchMedia for prefers-reduced-motion testing +const mockMatchMedia = (matches: boolean = false) => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }) +} + +// Mock timers +jest.useFakeTimers() + +describe('useButtonAnimation', () => { + beforeEach(() => { + jest.clearAllTimers() + mockMatchMedia(false) // Default: motion is not reduced + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + jest.useFakeTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + + describe('Initial state', () => { + it('should initialize with first value when iterateValues is provided', () => { + const iterateValues = [100, 200, 300] + const { result } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + // The hook immediately sets to first value due to the useEffect + expect(result.current.current).toBe(100) + expect(typeof result.current.onHover).toBe('function') + expect(typeof result.current.onLeave).toBe('function') + }) + + it('should initialize with 0 when iterateValues is empty', () => { + const { result } = renderHook(() => useButtonAnimation([], 1000)) + + expect(result.current.current).toBe(0) + }) + }) + + describe('Animation behavior', () => { + it('should cycle through iterateValues automatically', () => { + const iterateValues = [100, 200, 300] + const { result } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + // Initially should set to first value + expect(result.current.current).toBe(100) + + // After 1 second, should move to next value + act(() => { + jest.advanceTimersByTime(1000) + }) + expect(result.current.current).toBe(200) + + // After another second, should move to third value + act(() => { + jest.advanceTimersByTime(1000) + }) + expect(result.current.current).toBe(300) + + // After another second, should loop back to first value + act(() => { + jest.advanceTimersByTime(1000) + }) + expect(result.current.current).toBe(100) + }) + + it('should stop animation after one complete cycle', () => { + const iterateValues = [100, 200] + const { result } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + // Complete one full cycle (start + 2 values = 3 iterations) + expect(result.current.current).toBe(100) + + act(() => { + jest.advanceTimersByTime(1000) // First transition + }) + expect(result.current.current).toBe(200) + + act(() => { + jest.advanceTimersByTime(1000) // Second transition (back to start) + }) + expect(result.current.current).toBe(100) + + // Animation should stop here (after length + 1 iterations) + const currentValue = result.current.current + act(() => { + jest.advanceTimersByTime(2000) // Should not change anymore + }) + expect(result.current.current).toBe(currentValue) + }) + }) + + describe('Transition delay handling', () => { + it('should enforce minimum delay of 1000ms', () => { + const iterateValues = [100, 200] + const { result } = renderHook(() => useButtonAnimation(iterateValues, 500)) // Less than 1000 + + expect(result.current.current).toBe(100) + + // Should not change before 1000ms (minimum delay) + act(() => { + jest.advanceTimersByTime(500) + }) + expect(result.current.current).toBe(100) + + // Should change after 1000ms + act(() => { + jest.advanceTimersByTime(500) + }) + expect(result.current.current).toBe(200) + }) + + it('should disable animation when delay is -1', () => { + const iterateValues = [100, 200, 300] + const { result } = renderHook(() => useButtonAnimation(iterateValues, -1)) + + const initialValue = result.current.current + + act(() => { + jest.advanceTimersByTime(5000) + }) + + // Should not change from initial value + expect(result.current.current).toBe(initialValue) + }) + + it('should use custom delay when it is >= 1000ms', () => { + const iterateValues = [100, 200] + const customDelay = 2000 + const { result } = renderHook(() => useButtonAnimation(iterateValues, customDelay)) + + expect(result.current.current).toBe(100) + + // Should not change before custom delay + act(() => { + jest.advanceTimersByTime(1500) + }) + expect(result.current.current).toBe(100) + + // Should change after custom delay + act(() => { + jest.advanceTimersByTime(500) + }) + expect(result.current.current).toBe(200) + }) + }) + + describe('Hover functionality', () => { + it('should set current value and stop animation on hover', () => { + const iterateValues = [100, 200, 300] + const { result } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + expect(result.current.current).toBe(100) + + // Trigger hover + act(() => { + result.current.onHover(200) + }) + expect(result.current.current).toBe(200) + + // Animation should be stopped + act(() => { + jest.advanceTimersByTime(2000) + }) + expect(result.current.current).toBe(200) // Should not change + }) + + it('should restart animation on leave', () => { + const iterateValues = [100, 200, 300] + const { result } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + // Hover and stop animation + act(() => { + result.current.onHover(200) + }) + + // Leave should restart animation + act(() => { + result.current.onLeave() + }) + + // Animation should resume + act(() => { + jest.advanceTimersByTime(1000) + }) + expect(result.current.current).toBe(300) + }) + }) + + describe('Prefers reduced motion', () => { + it('should disable animation when user prefers reduced motion', () => { + mockMatchMedia(true) // User prefers reduced motion + + const iterateValues = [100, 200, 300] + const { result } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + const initialValue = result.current.current + + act(() => { + jest.advanceTimersByTime(5000) + }) + + // Should not animate when reduced motion is preferred + expect(result.current.current).toBe(initialValue) + }) + + it('should still allow manual hover/leave when reduced motion is enabled', () => { + mockMatchMedia(true) // User prefers reduced motion + + const iterateValues = [100, 200, 300] + const { result } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + // Hover should still work + act(() => { + result.current.onHover(200) + }) + expect(result.current.current).toBe(200) + + // Leave should work but not restart animation + act(() => { + result.current.onLeave() + }) + + act(() => { + jest.advanceTimersByTime(2000) + }) + expect(result.current.current).toBe(200) // Should not auto-animate + }) + }) + + describe('Edge cases', () => { + it('should handle empty iterateValues array', () => { + const { result } = renderHook(() => useButtonAnimation([], 1000)) + + act(() => { + jest.advanceTimersByTime(2000) + }) + + expect(result.current.current).toBe(0) + }) + + it('should handle single value in iterateValues', () => { + const iterateValues = [100] + const { result } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + expect(result.current.current).toBe(100) + + act(() => { + jest.advanceTimersByTime(1000) + }) + expect(result.current.current).toBe(100) // Should stay the same + }) + + it('should handle component unmounting during animation', () => { + const iterateValues = [100, 200, 300] + const { result, unmount } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + // Unmount component + unmount() + + // Should not throw errors when timers fire after unmount + expect(() => { + act(() => { + jest.advanceTimersByTime(2000) + }) + }).not.toThrow() + }) + }) + + describe('Hook stability', () => { + it('should create new function references on each render', () => { + const iterateValues = [100, 200, 300] + const { result, rerender } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + + const firstOnHover = result.current.onHover + const firstOnLeave = result.current.onLeave + + rerender() + + // Functions are recreated on each render (this is expected behavior) + expect(result.current.onHover).not.toBe(firstOnHover) + expect(result.current.onLeave).not.toBe(firstOnLeave) + }) + + it('should update when iterateValues change', () => { + const initialValues = [100, 200] + const { result, rerender } = renderHook( + ({ values, delay }) => useButtonAnimation(values, delay), + { + initialProps: { values: initialValues, delay: 1000 }, + }, + ) + + expect(result.current.current).toBe(100) + + // Change iterateValues + const newValues = [300, 400, 500] + rerender({ values: newValues, delay: 1000 }) + + // After rerender, it should update to the new first value + expect(result.current.current).toBe(300) + + act(() => { + jest.advanceTimersByTime(1000) + }) + expect(result.current.current).toBe(400) + }) + }) + + describe('SSR compatibility', () => { + it('should handle absence of matchMedia gracefully', () => { + const originalMatchMedia = window.matchMedia + + // Temporarily remove matchMedia + // @ts-ignore + delete window.matchMedia + + const iterateValues = [100, 200, 300] + + expect(() => { + renderHook(() => useButtonAnimation(iterateValues, 1000)) + }).not.toThrow() + + // Restore original matchMedia + if (originalMatchMedia) { + window.matchMedia = originalMatchMedia + } + }) + }) +}) diff --git a/src/hooks/useButtonAnimation.ts b/src/hooks/useButtonAnimation.ts index 20aa671..57333bc 100644 --- a/src/hooks/useButtonAnimation.ts +++ b/src/hooks/useButtonAnimation.ts @@ -9,7 +9,7 @@ type Props = { const useButtonAnimation = (iterateValues: number[], transitionDelay: number): Props => { const [current, setCurrent] = useState(0) const [update, setUpdate] = useState(true) - + const [animationIterationCount, setAnimationIterationCount] = useState(1) // Respect user's motion preferences with fallback for tests const prefersReducedMotion = typeof window !== 'undefined' && @@ -29,9 +29,18 @@ const useButtonAnimation = (iterateValues: number[], transitionDelay: number): P } if (iterateValues.length !== 0) { + // Ensure current is valid if (!iterateValues.includes(current) && update) setCurrent(iterateValues[0]) + // Stop the animation after one full cycle through all values + if (animationIterationCount === iterateValues.length + 1) { + setUpdate(false) + } + // Set up the timeout to change the current value timeout = setTimeout(() => { if (update && isMounted) { + // Count iterations to stop after one full cycle + setAnimationIterationCount((prev) => prev + 1) + // Move to the next value, or loop back to the start setCurrent( iterateValues[ iterateValues.includes(current) @@ -47,7 +56,14 @@ const useButtonAnimation = (iterateValues: number[], transitionDelay: number): P isMounted = false clearTimeout(timeout) } - }, [iterateValues, current, cappedTransitionDelay, update, prefersReducedMotion]) + }, [ + iterateValues, + current, + cappedTransitionDelay, + update, + prefersReducedMotion, + animationIterationCount, + ]) return { current, From f199f6e48757d1aca84f5eed56033e1e60bc4a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 14:05:49 +0200 Subject: [PATCH 06/13] chore(refactor): improve realTransitionTime function readability --- examples/basic.html | 2 -- src/Widgets/PaymentPlans/index.tsx | 16 +++++----------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/examples/basic.html b/examples/basic.html index b1746d6..bc8599c 100644 --- a/examples/basic.html +++ b/examples/basic.html @@ -59,8 +59,6 @@

450 €

container: '#alma-widget-payment-plans', locale: 'fr', purchaseAmount, - // transitionDelay: 1200, - suggestedPlan: [2, 3], }) } renderPaymentPlans() diff --git a/src/Widgets/PaymentPlans/index.tsx b/src/Widgets/PaymentPlans/index.tsx index a3ccc70..6a2baa5 100644 --- a/src/Widgets/PaymentPlans/index.tsx +++ b/src/Widgets/PaymentPlans/index.tsx @@ -34,7 +34,6 @@ type Props = { onModalClose?: (event: React.MouseEvent | React.KeyboardEvent) => void } -const VERY_LONG_TIME_IN_MS = 1000 * 3600 const DEFAULT_TRANSITION_TIME = 5500 /** @@ -85,8 +84,6 @@ const PaymentPlanWidget: FunctionComponent = ({ // Check if merchant has specified a suggested payment plan const isSuggestedPaymentPlanSpecified = suggestedPaymentPlan !== undefined // 👈 The merchant decided to focus a tab - // Check if merchant has specified a custom transition delay - const isTransitionSpecified = transitionDelay !== undefined // 👈 The merchant has specified a transition time // Modal state management const [isOpen, setIsOpen] = useState(false) @@ -112,20 +109,17 @@ const PaymentPlanWidget: FunctionComponent = ({ /** * Calculate the appropriate transition time based on merchant configuration - * If merchant specify a suggestedPaymentPlan and no transition, we set a very long transition delay. + * If merchant specify a suggestedPaymentPlan and no transition, we disable animation. * Otherwise, we set the transition delay specified by the merchant. * If none of those properties are specified, we set a default transition delay. * @returns {number} The transition time in milliseconds */ const realTransitionTime = useMemo((): number => { - if (isTransitionSpecified) { - return transitionDelay ?? DEFAULT_TRANSITION_TIME + if (isSuggestedPaymentPlanSpecified && !transitionDelay) { + return -1 // Disable animation } - if (isSuggestedPaymentPlanSpecified) { - return VERY_LONG_TIME_IN_MS - } - return DEFAULT_TRANSITION_TIME - }, [isTransitionSpecified, transitionDelay, isSuggestedPaymentPlanSpecified]) + return transitionDelay ?? DEFAULT_TRANSITION_TIME + }, [transitionDelay, isSuggestedPaymentPlanSpecified]) // Hook for managing plan cycling animation and user interactions const { current, onHover, onLeave } = useButtonAnimation(eligiblePlanKeys, realTransitionTime) From 44918d6aa5838e88ec8493df35a46dde2284d8e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 14:48:59 +0200 Subject: [PATCH 07/13] chore(rgaa): add missing aria attributes --- src/Widgets/EligibilityModal/index.tsx | 6 ++++-- src/assets/almaLogo.tsx | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Widgets/EligibilityModal/index.tsx b/src/Widgets/EligibilityModal/index.tsx index 660e2a2..6abcd19 100644 --- a/src/Widgets/EligibilityModal/index.tsx +++ b/src/Widgets/EligibilityModal/index.tsx @@ -76,8 +76,10 @@ const EligibilityModal: FunctionComponent = ({ ariaHideApp={false} scrollable isOpen - aria-labelledby="modal-title" - aria-describedby="modal-info-element" + aria={{ + labelledby: 'modal-title', + describedby: 'modal-info-element', + }} > { id: 'accessibility.alma-logo.aria-label', defaultMessage: 'Logo Alma - Solution de paiement en plusieurs fois', })} + focusable={false} + aria-hidden role="img" > Date: Fri, 29 Aug 2025 15:04:46 +0200 Subject: [PATCH 08/13] chore(rgaa): improve focus style --- .../EligibilityModal/components/Info/Info.module.css | 6 ++++++ .../components/Schedule/Schedule.module.css | 7 ++++++- src/Widgets/PaymentPlans/PaymentPlans.module.css | 9 ++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Widgets/EligibilityModal/components/Info/Info.module.css b/src/Widgets/EligibilityModal/components/Info/Info.module.css index 6588493..f7d7283 100644 --- a/src/Widgets/EligibilityModal/components/Info/Info.module.css +++ b/src/Widgets/EligibilityModal/components/Info/Info.module.css @@ -7,6 +7,12 @@ font-family: 'Venn', sans-serif; } +.list:focus, +.list:focus-visible { + outline: 1px solid var(--alma-orange); + outline-offset: 2px; +} + .listItem { display: flex; align-items: center; diff --git a/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css b/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css index 83b86cd..b775190 100644 --- a/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css +++ b/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css @@ -1,9 +1,14 @@ .schedule { padding: 0 24px; - margin-bottom: 16px; + margin: 6px 0; font-family: 'Venn', sans-serif; } +.schedule:focus, +.schedule:focus-visible { + outline: 1px auto var(--alma-orange); +} + .scheduleItem { list-style-type: none; } diff --git a/src/Widgets/PaymentPlans/PaymentPlans.module.css b/src/Widgets/PaymentPlans/PaymentPlans.module.css index e73d8a5..9adb3c7 100644 --- a/src/Widgets/PaymentPlans/PaymentPlans.module.css +++ b/src/Widgets/PaymentPlans/PaymentPlans.module.css @@ -65,11 +65,18 @@ transform: scale(1.05); } -.planButton:focus { +.planButton:focus, +.planButton:focus-visible { outline: 1px solid var(--alma-orange); outline-offset: 2px; } +.planButton.monochrome:focus, +.planButton.monochrome:focus-visible { + outline: 1px solid var(--black); + outline-offset: 2px; +} + .plan.notEligible { cursor: not-allowed; opacity: 0.6; From 554a33bdfb862f4922a80a659d7fbc4a3edeb838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 15:35:13 +0200 Subject: [PATCH 09/13] chore(rgaa): improve screen reader for region sections --- .../components/Info/index.tsx | 3 +- .../components/Schedule/Schedule.module.css | 14 +++--- .../components/Schedule/index.tsx | 37 +++++++++------- src/Widgets/EligibilityModal/index.tsx | 1 - .../SkipLinks/__tests__/SkipLinks.test.tsx | 44 +++++++++++++++++++ src/components/SkipLinks/index.tsx | 17 ++++++- 6 files changed, 93 insertions(+), 23 deletions(-) diff --git a/src/Widgets/EligibilityModal/components/Info/index.tsx b/src/Widgets/EligibilityModal/components/Info/index.tsx index 9b926f1..0eb6b2b 100644 --- a/src/Widgets/EligibilityModal/components/Info/index.tsx +++ b/src/Widgets/EligibilityModal/components/Info/index.tsx @@ -16,6 +16,7 @@ const Info: FC<{ isCurrentPlanP1X: boolean; id?: string }> = ({ isCurrentPlanP1X data-testid="modal-info-element" role="region" aria-labelledby="payment-info-title" + aria-describedby="payment-info-description" tabIndex={-1} >

@@ -24,7 +25,7 @@ const Info: FC<{ isCurrentPlanP1X: boolean; id?: string }> = ({ isCurrentPlanP1X defaultMessage="Comment procéder au paiement" />

{' '} -
    +
    1. 1
      diff --git a/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css b/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css index b775190..c9550a4 100644 --- a/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css +++ b/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css @@ -1,14 +1,18 @@ +.scheduleContainer { + padding: 6px; +} + +.scheduleContainer:focus, +.scheduleContainer:focus-visible { + outline: 1px auto var(--alma-orange); +} + .schedule { padding: 0 24px; margin: 6px 0; font-family: 'Venn', sans-serif; } -.schedule:focus, -.schedule:focus-visible { - outline: 1px auto var(--alma-orange); -} - .scheduleItem { list-style-type: none; } diff --git a/src/Widgets/EligibilityModal/components/Schedule/index.tsx b/src/Widgets/EligibilityModal/components/Schedule/index.tsx index 5b6b215..f893d4e 100644 --- a/src/Widgets/EligibilityModal/components/Schedule/index.tsx +++ b/src/Widgets/EligibilityModal/components/Schedule/index.tsx @@ -5,31 +5,38 @@ import { FormattedMessage } from 'react-intl' import { EligibilityPlan } from '@/types' import Installment from 'components/Installments/Installment' +import TotalBlock from 'components/Installments/TotalBlock' import STATIC_CUSTOMISATION_CLASSES from 'Widgets/EligibilityModal/classNames.const' import s from 'Widgets/EligibilityModal/components/Schedule/Schedule.module.css' const Schedule: FC<{ currentPlan: EligibilityPlan; id?: string }> = ({ currentPlan, id }) => ( -
      +

      -
        - {(currentPlan?.payment_plan || []).map((installment, index) => ( -
      • - -
      • - ))} -
      +
      +
        + {(currentPlan?.payment_plan || []).map((installment, index) => ( +
      • + +
      • + ))} +
      + +
      ) diff --git a/src/Widgets/EligibilityModal/index.tsx b/src/Widgets/EligibilityModal/index.tsx index 6abcd19..71d5ab1 100644 --- a/src/Widgets/EligibilityModal/index.tsx +++ b/src/Widgets/EligibilityModal/index.tsx @@ -113,7 +113,6 @@ const EligibilityModal: FunctionComponent = ({
      -
      )} diff --git a/src/components/SkipLinks/__tests__/SkipLinks.test.tsx b/src/components/SkipLinks/__tests__/SkipLinks.test.tsx index ac328cc..2ed0dfc 100644 --- a/src/components/SkipLinks/__tests__/SkipLinks.test.tsx +++ b/src/components/SkipLinks/__tests__/SkipLinks.test.tsx @@ -1,6 +1,7 @@ import React from 'react' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { IntlProvider } from 'react-intl' import '@testing-library/jest-dom' @@ -68,4 +69,47 @@ describe('SkipLinks', () => { ) expect(container.firstChild).toHaveClass('custom-class') }) + + it('should focus target element when skip link is clicked', async () => { + const user = userEvent.setup() + + // Create a target element in the DOM + const targetElement = document.createElement('div') + targetElement.id = 'payment-schedule' + targetElement.tabIndex = -1 + document.body.appendChild(targetElement) + + // Mock focus method + const focusSpy = jest.spyOn(targetElement, 'focus') + + renderWithIntl() + + // Click on the payment schedule skip link + const skipLink = screen.getByRole('link', { name: 'Aller au calendrier de paiement' }) + await user.click(skipLink) + + // Wait for the setTimeout to execute + await new Promise((resolve) => { + setTimeout(resolve, 10) + }) + + // Verify that focus was called on the target element + expect(focusSpy).toHaveBeenCalled() + + // Clean up + document.body.removeChild(targetElement) + focusSpy.mockRestore() + }) + + it('should handle click on skip link when target element does not exist', async () => { + const user = userEvent.setup() + + renderWithIntl() + + // Click on a skip link for a non-existent element + const skipLink = screen.getByRole('link', { name: 'Aller aux options de paiement' }) + + // This should not throw an error + await expect(user.click(skipLink)).resolves.not.toThrow() + }) }) diff --git a/src/components/SkipLinks/index.tsx b/src/components/SkipLinks/index.tsx index 2bdbb0f..96dbd5b 100644 --- a/src/components/SkipLinks/index.tsx +++ b/src/components/SkipLinks/index.tsx @@ -17,6 +17,21 @@ type Props = { const SkipLinks: FC = ({ skipLinks, className }) => { const intl = useIntl() + const handleSkipLinkClick = (event: React.MouseEvent) => { + const href = event.currentTarget.getAttribute('href') + if (href && href.startsWith('#')) { + const targetId = href.substring(1) + const targetElement = document.getElementById(targetId) + + if (targetElement) { + // Small delay to allow browser to scroll first + setTimeout(() => { + targetElement.focus() + }, 0) + } + } + } + return (
      = ({ skipLinks, className }) => {
        {skipLinks.map(({ href, labelId, defaultMessage }) => (
      • - +
      • From e98a426c6673d6e8bb792e4531da745c7b78c0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 15:40:19 +0200 Subject: [PATCH 10/13] docs(rgaa): update docs with new fixes --- accessibility/ACCESSIBILITY_DOCUMENTATION.md | 237 +++++++ accessibility/ACCESSIBILITY_IMPLEMENTATION.md | 514 ---------------- accessibility/ACCESSIBILITY_TESTING.md | 582 ------------------ accessibility/RGAA_AUDIT_REPORT.md | 406 ------------ 4 files changed, 237 insertions(+), 1502 deletions(-) create mode 100644 accessibility/ACCESSIBILITY_DOCUMENTATION.md delete mode 100644 accessibility/ACCESSIBILITY_IMPLEMENTATION.md delete mode 100644 accessibility/ACCESSIBILITY_TESTING.md delete mode 100644 accessibility/RGAA_AUDIT_REPORT.md diff --git a/accessibility/ACCESSIBILITY_DOCUMENTATION.md b/accessibility/ACCESSIBILITY_DOCUMENTATION.md new file mode 100644 index 0000000..b058569 --- /dev/null +++ b/accessibility/ACCESSIBILITY_DOCUMENTATION.md @@ -0,0 +1,237 @@ +# Accessibility Documentation - RGAA Compliance + +This document provides a comprehensive overview of all accessibility improvements and RGAA (Référentiel Général d'Amélioration de l'Accessibilité) compliance measures implemented in the Alma Payment Widgets project. + +## Table of Contents + +1. [Overview](#overview) +2. [RGAA Compliance Summary](#rgaa-compliance-summary) +3. [Implemented Accessibility Features](#implemented-accessibility-features) +4. [Component-Specific Accessibility](#component-specific-accessibility) +5. [Testing Strategy](#testing-strategy) +6. [Development Guidelines](#development-guidelines) +7. [Future Improvements](#future-improvements) + +## Overview + +The Alma Payment Widgets have been designed and developed with accessibility as a core principle, ensuring compliance with RGAA guidelines and WCAG 2.1 AA standards. This implementation provides an inclusive experience for all users, including those using assistive technologies. + +## RGAA Compliance Summary + +### ✅ Fully Implemented Criteria + +#### 1. Images and Media (RGAA 1) +- **1.1**: All decorative images have appropriate `role="img"` attributes +- **1.3**: Informative images have descriptive alternative text +- Implementation: SVG components include proper `role="img"` and `aria-label` attributes + +#### 2. Frames (RGAA 2) +- **2.1**: Modal dialogs have proper `role="dialog"` attributes +- **2.2**: Frames have descriptive titles via `aria-labelledby` + +#### 3. Colors (RGAA 3) +- **3.1**: Information is not conveyed by color alone +- **3.2**: Color contrast meets WCAG AA standards +- Implementation: Visual indicators combined with text and ARIA attributes + +#### 4. Multimedia (RGAA 4) +- Not applicable (no multimedia content in payment widgets) + +#### 5. Tables (RGAA 5) +- Not applicable (no data tables in current implementation) + +#### 6. Links (RGAA 6) +- **6.1**: Links have explicit context and purpose +- **6.2**: Skip links implemented for keyboard navigation +- Implementation: SkipLinks component with proper focus management + +#### 7. Scripts (RGAA 7) +- **7.1**: Scripts are accessible and don't interfere with assistive technologies +- **7.3**: User can navigate and interact using keyboard only +- **7.4**: Status changes are announced to screen readers +- Implementation: Custom `useAnnounceText` hook for dynamic content announcements + +#### 8. Mandatory Elements (RGAA 8) +- **8.2**: Document language is properly declared +- **8.9**: Page title is descriptive and unique +- Implementation: Proper HTML structure and internationalization + +#### 9. Information Structure (RGAA 9) +- **9.1**: Proper heading hierarchy (h1, h2, h3) +- **9.2**: Document structure is logical and semantic +- **9.3**: Lists are properly marked up +- Implementation: Semantic HTML with screen reader only headings using `sr-only` class + +#### 10. Presentation (RGAA 10) +- **10.1**: CSS is used for presentation, not HTML attributes +- **10.3**: Information remains available when CSS is disabled +- **10.7**: Focus is visible and properly managed +- Implementation: CSS modules with proper focus indicators + +#### 11. Forms (RGAA 11) +- **11.1**: Form controls have labels or accessible names +- **11.2**: Required fields are properly indicated +- **11.10**: Form controls are grouped logically +- **11.11**: Error messages are associated with form controls +- Implementation: Radio groups with proper `role="radiogroup"` and `aria-labelledby` + +#### 12. Navigation (RGAA 12) +- **12.1**: Navigation areas are identified +- **12.6**: Grouped navigation links have accessible names +- **12.7**: Skip links are provided +- Implementation: SkipLinks component with `role="navigation"` + +#### 13. Consultation (RGAA 13) +- **13.1**: User can control automatic content changes +- **13.3**: Documents are accessible +- **13.8**: Content changes are announced +- Implementation: `aria-live` regions for dynamic content updates + +## Implemented Accessibility Features + +### 1. Semantic HTML Structure +```html + +
        +

        Payment Options

        + +
        +``` + +### 2. ARIA Attributes +- **aria-labelledby**: Links headings to content sections +- **aria-describedby**: Provides additional context +- **aria-live**: Announces dynamic content changes +- **aria-pressed**: Indicates button states +- **aria-current**: Identifies current selection +- **role**: Defines element purpose (dialog, region, radiogroup, etc.) + +### 3. Keyboard Navigation +- Full keyboard accessibility with logical tab order +- Custom focus management for modals and dynamic content +- Skip links for efficient navigation +- Proper `tabIndex` management + +### 4. Screen Reader Support +- Custom `useAnnounceText` hook for dynamic announcements +- Live regions for status updates +- Screen reader only content with `sr-only` class +- Descriptive alternative text for images + +### 5. Focus Management +- Visible focus indicators +- Focus trap in modals +- Programmatic focus setting for skip links +- Logical tab order throughout the interface + +## Component-Specific Accessibility + +### EligibilityModal +- **Role**: `dialog` with `aria-modal="true"` +- **Labeling**: `aria-labelledby` references modal title +- **Focus**: Automatic focus management on open/close +- **Structure**: Proper heading hierarchy with hidden headings for screen readers + +### PaymentPlans +- **Radio Group**: `role="radiogroup"` for payment options +- **State Management**: `aria-pressed` and `aria-current` for selected options +- **Announcements**: Dynamic selection announcements via `useAnnounceText` +- **Keyboard**: Full keyboard navigation support + +### SkipLinks +- **Navigation**: `role="navigation"` with descriptive `aria-label` +- **Focus Management**: Programmatic focus setting on target elements +- **Accessibility**: First elements in tab order for immediate access + +### Schedule Component +- **Structure**: `role="region"` with hidden heading +- **Context**: `aria-labelledby` and `aria-describedby` for clarity +- **Content**: Semantic list structure for installments + +### Loading States +- **Status**: `role="status"` with `aria-live="polite"` +- **Feedback**: Clear loading indicators for all users +- **Announcements**: Screen reader notifications for state changes + +## Testing Strategy + +### 1. Automated Testing +- Jest accessibility tests using `@testing-library/jest-dom` +- ARIA attribute validation +- Keyboard navigation testing +- Focus management verification + +### 2. Manual Testing +- Screen reader testing (NVDA, JAWS, VoiceOver) +- Keyboard-only navigation +- High contrast mode compatibility +- Zoom testing up to 200% + +### 3. Test Coverage +- All interactive elements have accessibility tests +- ARIA attributes are validated in component tests +- Focus management is tested for modal interactions +- Announcement functionality is thoroughly tested + +## Development Guidelines + +### 1. ARIA Best Practices +- Use semantic HTML first, ARIA as enhancement +- Provide meaningful labels and descriptions +- Implement proper state management +- Use live regions for dynamic content + +### 2. Focus Management +- Ensure visible focus indicators +- Implement logical tab order +- Manage focus for dynamic content +- Provide skip links for complex interfaces + +### 3. Content Structure +- Use proper heading hierarchy +- Implement semantic markup +- Provide alternative text for images +- Structure content logically + +### 4. Testing Requirements +- Write accessibility tests for all components +- Test with keyboard navigation +- Validate ARIA implementations +- Test with screen readers when possible + +## Future Improvements + +### 1. Enhanced Error Handling +- Implement `aria-invalid` for form validation +- Add `aria-describedby` for error messages +- Improve error announcement timing + +### 2. Advanced Navigation +- Consider implementing `aria-roledescription` for custom controls +- Add landmark navigation for complex layouts +- Implement breadcrumb navigation if applicable + +### 3. Personalization +- Support for reduced motion preferences +- High contrast theme options +- Font size customization support + +### 4. Testing Enhancements +- Automated accessibility testing in CI/CD +- Regular accessibility audits +- User testing with assistive technology users + +## Conclusion + +The Alma Payment Widgets demonstrate a strong commitment to accessibility, implementing comprehensive RGAA compliance measures that ensure an inclusive experience for all users. The combination of semantic HTML, proper ARIA usage, keyboard navigation, and screen reader support creates a robust accessible foundation. + +The implementation includes custom accessibility hooks, comprehensive testing coverage, and clear development guidelines to maintain accessibility standards as the project evolves. + +For questions or improvements regarding accessibility, please refer to the RGAA guidelines and consult with accessibility experts during development. + +--- + +**Last Updated**: August 29, 2025 +**RGAA Version**: 4.1 +**WCAG Compliance**: 2.1 AA +**Testing Tools**: Jest, Testing Library, Manual Screen Reader Testing diff --git a/accessibility/ACCESSIBILITY_IMPLEMENTATION.md b/accessibility/ACCESSIBILITY_IMPLEMENTATION.md deleted file mode 100644 index 23b9059..0000000 --- a/accessibility/ACCESSIBILITY_IMPLEMENTATION.md +++ /dev/null @@ -1,514 +0,0 @@ -# Accessibility Implementation Guide - -## Overview - -This document outlines the comprehensive accessibility implementation in the Alma Widgets project, following WCAG 2.1 AA guidelines and French RGAA (Référentiel Général d'Amélioration de l'Accessibilité) standards. - -## Table of Contents - -1. [Standards Compliance](#standards-compliance) -2. [Implementation Details](#implementation-details) -3. [Components Overview](#components-overview) -4. [Recent Enhancements](#recent-enhancements) -5. [Testing Strategy](#testing-strategy) -6. [RGAA Compliance](#rgaa-compliance) -7. [Best Practices](#best-practices) - -## Standards Compliance - -### WCAG 2.1 AA Compliance -- **Level AA conformance** across all interactive elements -- **Perceivable**: Alternative text, color contrast, responsive design -- **Operable**: Advanced keyboard navigation, no seizure-inducing content -- **Understandable**: Clear language, predictable functionality -- **Robust**: Compatible with assistive technologies - -### RGAA 4.1 Compliance -- French government accessibility standards -- All blocking (bloquant) and major (majeur) criteria addressed -- Automated and manual testing procedures implemented - -## Implementation Details - -### 1. Semantic HTML Structure - -#### Skip Links -```typescript -// Implemented in: src/components/SkipLinks/index.tsx -// Provides navigation shortcuts for keyboard users -
        -
          - {skipLinks.map(({ href, labelId, defaultMessage }) => ( -
        • - - - -
        • - ))} -
        -
        -``` - -#### Modal Implementation -```typescript -// src/components/Modal/index.tsx - noScroll.on()} - onAfterClose={() => noScroll.off()} -> - - -``` - -### 2. ARIA Implementation - -#### Payment Plan Selection (Radiogroup Pattern) -```typescript -// src/Widgets/PaymentPlans/index.tsx -
        - {eligibilityPlans.map((plan, key) => ( -
        -``` - -#### Live Announcements with Custom Hook -```typescript -// Enhanced announcement system using useAnnounceText hook -// src/hooks/useAnnounceText.ts -const { announceText, announce } = useAnnounceText() - -// Automatic announcements when payment plan changes -useEffect(() => { - if (eligibilityPlans[current] && status === statusResponse.SUCCESS) { - const planDescription = currentPlan.installments_count === 1 - ? intl.formatMessage({ id: 'payment-plan-strings.pay.now.button' }) - : `${currentPlan.installments_count}x` - - announce( - intl.formatMessage( - { id: 'accessibility.plan-selection-changed' }, - { planDescription } - ), - 1000 // Auto-clear after 1 second - ) - } -}, [current, eligibilityPlans, intl, status, announce]) - -// Live region for announcements -
        - {announceText} -
        -``` - -### 3. Enhanced Keyboard Navigation - -#### Advanced Payment Plan Navigation -```typescript -// Complete keyboard navigation with proper focus management -const navigateToEligiblePlan = (direction: 'next' | 'prev', currentIndex: number) => { - const currentEligibleIndex = eligiblePlanKeys.indexOf(currentIndex) - - if (currentEligibleIndex === -1) return - - let newEligibleIndex - if (direction === 'next') { - newEligibleIndex = currentEligibleIndex + 1 - if (newEligibleIndex >= eligiblePlanKeys.length) return - } else { - newEligibleIndex = currentEligibleIndex - 1 - if (newEligibleIndex < 0) return - } - - const newPlanIndex = eligiblePlanKeys[newEligibleIndex] - onHover(newPlanIndex) - - // Focus the new button automatically - buttonRefs.current[newPlanIndex]?.focus() -} - -// Keyboard event handling -onKeyDown={(e) => { - if (!isEligible) return - - // Arrow navigation between eligible plans only - if (e.key === 'ArrowLeft') { - e.preventDefault() - navigateToEligiblePlan('prev', key) - } else if (e.key === 'ArrowRight') { - e.preventDefault() - navigateToEligiblePlan('next', key) - } else if (e.key === 'Home') { - e.preventDefault() - navigateToEdgePlan('first') - } else if (e.key === 'End') { - e.preventDefault() - navigateToEdgePlan('last') - } -}} -``` - -#### Modal Navigation Enhancement -```typescript -// src/Widgets/EligibilityModal/components/EligibilityPlansButtons/index.tsx -const navigateToPlan = (newIndex: number) => { - if (newIndex >= 0 && newIndex < eligibilityPlans.length) { - setCurrentPlanIndex(newIndex) - // Focus the new button with timeout to avoid conflicts - setTimeout(() => buttonRefs.current[newIndex]?.focus(), 0) - } -} - -onKeyDown={(e) => { - // Arrow navigation between plans - if (e.key === 'ArrowLeft') { - e.preventDefault() - navigateToPlan(key - 1) - } else if (e.key === 'ArrowRight') { - e.preventDefault() - navigateToPlan(key + 1) - } else if (e.key === 'Home') { - e.preventDefault() - navigateToPlan(0) - } else if (e.key === 'End') { - e.preventDefault() - navigateToPlan(eligibilityPlans.length - 1) - } -}} -``` - -#### Widget Activation -```typescript -// Main widget button with Enter and Space key support -onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - handleOpenModal(e) - } -}} -role="button" -tabIndex={0} -aria-label={intl.formatMessage({ - id: 'accessibility.payment-widget.open-button.aria-label', - defaultMessage: 'Open Alma payment options' -})} -``` - -### 4. Focus Management - -#### Enhanced Focus Management with Refs -```typescript -// Refs for managing focus on plan buttons -const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]) - -// Initialize button refs array -useEffect(() => { - buttonRefs.current = buttonRefs.current.slice(0, eligibilityPlans.length) -}, [eligibilityPlans.length]) - -// Proper focus management for disabled payment plans -tabIndex={plan.eligible ? 0 : -1} -aria-disabled={!plan.eligible} - -// Only eligible plans receive focus -onFocus={isEligible ? () => onHover(key) : undefined} -``` - -#### Modal Focus Management -```typescript -// Automatic scroll prevention and focus handling -onAfterOpen={() => noScroll.on()} -onAfterClose={() => noScroll.off()} -shouldCloseOnEsc={true} -shouldCloseOnOverlayClick={true} -``` - -### 5. Custom Hooks for Accessibility - -#### useAnnounceText Hook -```typescript -// src/hooks/useAnnounceText.ts -export const useAnnounceText = () => { - const [announceText, setAnnounceText] = useState('') - - const announce = useCallback((text: string, clearDelay: number = 1000) => { - setAnnounceText(text) - - // Clear announcement after the specified delay - const timer = setTimeout(() => setAnnounceText(''), clearDelay) - return () => clearTimeout(timer) - }, []) - - const clearAnnouncement = useCallback(() => { - setAnnounceText('') - }, []) - - return { - announceText, - announce, - clearAnnouncement, - } -} -``` - -### 6. Color and Contrast - -#### Enhanced Visual States -```css -/* High contrast focus indicators */ -.planButton:focus { - outline: 2px solid var(--focus-color); - outline-offset: 2px; -} - -/* Active state with sufficient contrast */ -.active { - background: var(--alma-orange); - color: var(--white); - /* 4.5:1 contrast ratio achieved */ -} - -/* Disabled state with clear visual indication */ -.notEligible { - opacity: 0.6; - cursor: not-allowed; -} -``` - -### 7. Internationalization (i18n) - -#### Multi-language Accessibility Support -```typescript -// All accessibility strings properly internationalized -const messages = { - 'accessibility.payment-widget.open-button.aria-label': 'Open Alma payment options', - 'accessibility.payment-options.radiogroup.aria-label': 'Available payment options', - 'accessibility.payment-plan.option.aria-label': 'Payment option {planDescription}', - 'accessibility.plan-selection-changed': 'Selected plan: {planDescription}', - 'accessibility.close-button.aria-label': 'Close window', - 'accessibility.skip-links.navigation.aria-label': 'Quick navigation', - 'accessibility.payment-plans-title': 'Available payment options', - 'accessibility.payment-plan-button.aria-label': 'Select payment plan {planName}' -} -``` - -## Components Overview - -### PaymentPlans Widget -**Enhanced Accessibility Features:** -- ✅ Complete radiogroup implementation with ARIA -- ✅ Advanced keyboard navigation (arrows, Home, End) with focus management -- ✅ Live announcements for plan changes using custom hook -- ✅ Proper focus management for disabled states with refs -- ✅ Screen reader compatible descriptions -- ✅ Touch and mouse interaction support -- ✅ Automatic focus following keyboard navigation - -**ARIA Attributes:** -- `role="radiogroup"` for payment plan container -- `role="radio"` for individual payment options -- `aria-checked` for selection state -- `aria-current` for current focused item -- `aria-describedby` linking to payment information -- `aria-disabled` for ineligible plans -- `aria-label` with descriptive text for each option - -### EligibilityModal Component -**Enhanced Accessibility Features:** -- ✅ Proper dialog role implementation -- ✅ Enhanced keyboard navigation with focus management -- ✅ Automatic scroll prevention -- ✅ ESC key and overlay click to close -- ✅ Focus management with react-modal and custom refs -- ✅ Accessible close button with icon hiding -- ✅ Screen reader announcements - -**ARIA Attributes:** -- `role="dialog"` -- `aria-modal="true"` -- `aria-label` for close button -- `aria-hidden="true"` for decorative icons -- `role="group"` for plan selection buttons -- `aria-pressed` for button states - -### SkipLinks Component -**Enhanced Accessibility Features:** -- ✅ Semantic navigation structure -- ✅ Configurable skip link targets -- ✅ Proper ARIA labeling -- ✅ Internationalized link text -- ✅ Keyboard-first design - -**Implementation:** -- `role="navigation"` for container -- `aria-label` for navigation purpose -- Semantic `
          ` and `
        • ` structure -- Descriptive link text - -### useAnnounceText Hook -**Enhanced Accessibility Features:** -- ✅ Reusable announcement system -- ✅ Automatic cleanup to prevent announcement spam -- ✅ Configurable delay timing -- ✅ Manual clearing capability -- ✅ Stable function references with useCallback - -**Implementation:** -```typescript -const { announceText, announce, clearAnnouncement } = useAnnounceText() - -// Use in live region -
          - {announceText} -
          -``` - -## Recent Enhancements - -### 1. Keyboard Navigation Improvements -- **Enhanced arrow key navigation** that properly handles eligible plans only -- **Automatic focus management** using refs to ensure visual focus follows keyboard navigation -- **Home/End key support** for quick navigation to first/last items -- **Bidirectional navigation** that works smoothly in both directions - -### 2. Announcement System Refactoring -- **Custom useAnnounceText hook** for reusable announcement functionality -- **Automatic cleanup** to prevent screen reader spam -- **Configurable timing** for different announcement scenarios -- **Stable function references** to prevent unnecessary re-renders - -### 3. Focus Management Enhancement -- **useRef for button references** enabling programmatic focus control -- **Proper tabindex management** ensuring only focusable elements receive tab focus -- **Focus following navigation** so visual focus matches keyboard navigation -- **Edge case handling** for navigation boundaries - -### 4. Code Quality Improvements -- **All comments translated to English** for consistency -- **ESLint compliance** with proper import sorting and no-return-assign rules -- **TypeScript strict mode** compliance with proper typing -- **Performance optimizations** with useCallback for stable references - -## Testing Strategy - -### Automated Testing -```typescript -// useAnnounceText hook tests -describe('useAnnounceText', () => { - it('should announce text and clear after delay', () => { - const { result } = renderHook(() => useAnnounceText()) - - act(() => { - result.current.announce('Test announcement', 1000) - }) - - expect(result.current.announceText).toBe('Test announcement') - - act(() => { - jest.advanceTimersByTime(1000) - }) - - expect(result.current.announceText).toBe('') - }) -}) -``` - -### Manual Testing Checklist -- [ ] Screen reader navigation through all interactive elements -- [ ] Keyboard-only navigation using Tab, Arrow keys, Home, End -- [ ] Focus visibility and management -- [ ] Live announcements timing and content -- [ ] Modal focus trapping and restoration -- [ ] Color contrast verification -- [ ] Zoom testing up to 200% - -### Screen Reader Testing -- **NVDA** (Windows): All interactive elements properly announced -- **JAWS** (Windows): Navigation commands work as expected -- **VoiceOver** (macOS): Proper reading order and interaction -- **TalkBack** (Android): Mobile accessibility verification - -## RGAA Compliance - -### Criteria 7: Scripts -- ✅ **7.1**: Script-generated content is accessible -- ✅ **7.2**: Scripts preserve form control accessibility -- ✅ **7.3**: Focus management is properly handled -- ✅ **7.4**: Status messages are announced to assistive technologies -- ✅ **7.5**: Scripts don't interfere with assistive technologies - -### Criteria 10: Presentation -- ✅ **10.1**: CSS is not required for information access -- ✅ **10.2**: Invisible content is properly handled -- ✅ **10.7**: Focus is visible for all interactive elements -- ✅ **10.14**: Content remains accessible when CSS is disabled - -### Criteria 12: Navigation -- ✅ **12.1**: Skip links are implemented -- ✅ **12.2**: Navigation is consistent across pages -- ✅ **12.8**: Tab order is logical and predictable -- ✅ **12.9**: Keyboard shortcuts don't conflict with assistive technologies - -## Best Practices - -### 1. Development Guidelines -- Use semantic HTML as the foundation -- Implement ARIA attributes progressively -- Test with real assistive technologies -- Maintain keyboard navigation patterns -- Provide clear focus indicators - -### 2. Testing Integration -- Include accessibility testing in CI/CD pipeline -- Regular manual testing with screen readers -- Automated testing with jest and @testing-library -- Color contrast verification tools -- Keyboard navigation testing protocols - -### 3. Documentation Maintenance -- Keep accessibility documentation updated with code changes -- Document all ARIA patterns and their purposes -- Maintain testing checklists and procedures -- Regular accessibility audits and reviews - ---- - -**Last Updated**: August 2025 -**Version**: 3.1.1 -**WCAG Level**: AA -**RGAA Compliance**: 4.1 diff --git a/accessibility/ACCESSIBILITY_TESTING.md b/accessibility/ACCESSIBILITY_TESTING.md deleted file mode 100644 index e5cc7e8..0000000 --- a/accessibility/ACCESSIBILITY_TESTING.md +++ /dev/null @@ -1,582 +0,0 @@ -# Accessibility Testing Guide - -## Overview - -This document provides comprehensive guidance for testing accessibility in the Alma Widgets project. It covers automated testing strategies, manual testing procedures, and compliance verification methods for the enhanced implementation with improved keyboard navigation and announcement systems. - -## Table of Contents - -1. [Testing Strategy](#testing-strategy) -2. [Automated Testing](#automated-testing) -3. [Manual Testing](#manual-testing) -4. [Custom Hook Testing](#custom-hook-testing) -5. [Keyboard Navigation Testing](#keyboard-navigation-testing) -6. [Screen Reader Testing](#screen-reader-testing) -7. [RGAA Compliance Testing](#rgaa-compliance-testing) -8. [Testing Tools](#testing-tools) -9. [Continuous Integration](#continuous-integration) - -## Testing Strategy - -### Multi-layered Approach -- **Automated testing**: jest-axe integration for WCAG compliance + custom hook testing -- **Manual testing**: Enhanced keyboard navigation, screen reader testing -- **User testing**: Real users with disabilities -- **Compliance auditing**: RGAA 4.1 standards verification -- **Focus management testing**: Programmatic focus behavior verification - -### Testing Pyramid -``` - Manual User Testing - / \ - Manual Testing Compliance Audits - / | \ -Custom Hook Tests | Enhanced Navigation Testing - \ | / - Automated Testing (jest-axe, axe-core) -``` - -## Automated Testing - -### Jest-Axe Integration - -#### Setup -```typescript -// setupTests.ts -import { toHaveNoViolations } from 'jest-axe' -expect.extend(toHaveNoViolations) -``` - -#### PaymentPlans Widget Tests -```typescript -import { axe } from 'jest-axe' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import PaymentPlansWidget from 'Widgets/PaymentPlans' - -describe('PaymentPlans Accessibility Tests', () => { - const defaultProps = { - purchaseAmount: 100, - apiData: { /* mock api config */ }, - eligibilityPlans: [ - { installments_count: 1, eligible: true }, - { installments_count: 3, eligible: true }, - { installments_count: 4, eligible: false } - ] - } - - beforeEach(() => { - jest.useFakeTimers() - }) - - afterEach(() => { - jest.runOnlyPendingTimers() - jest.useRealTimers() - }) - - it('should not have accessibility violations', async () => { - const { container } = render() - const results = await axe(container) - expect(results).toHaveNoViolations() - }) - - it('should implement proper radiogroup pattern', async () => { - render() - - const radiogroup = screen.getByRole('radiogroup') - expect(radiogroup).toHaveAttribute('aria-label', 'Available payment options') - - const radioButtons = screen.getAllByRole('radio') - expect(radioButtons).toHaveLength(3) - - radioButtons.forEach((button, index) => { - expect(button).toHaveAttribute('aria-checked') - expect(button).toHaveAttribute('aria-describedby', 'payment-info-text') - expect(button).toHaveAttribute('aria-label') - - // Check disabled state handling - if (!defaultProps.eligibilityPlans[index].eligible) { - expect(button).toHaveAttribute('aria-disabled', 'true') - expect(button).toHaveAttribute('tabindex', '-1') - } else { - expect(button).toHaveAttribute('tabindex', '0') - } - }) - }) - - it('should announce plan changes using useAnnounceText hook', async () => { - render() - - const liveRegion = screen.getByRole('alert') - expect(liveRegion).toHaveAttribute('aria-live', 'assertive') - - // Simulate plan change and verify announcement - const firstPlan = screen.getAllByRole('radio')[0] - fireEvent.mouseEnter(firstPlan) - - await waitFor(() => { - expect(liveRegion).toHaveTextContent('Selected plan: 1x') - }) - - // Verify announcement clears after delay - jest.advanceTimersByTime(1000) - - await waitFor(() => { - expect(liveRegion).toHaveTextContent('') - }) - }) - - it('should handle keyboard navigation with focus management', async () => { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) - render() - - const radioButtons = screen.getAllByRole('radio') - const eligibleButtons = radioButtons.filter(button => - button.getAttribute('aria-disabled') !== 'true' - ) - - // Focus first eligible button - eligibleButtons[0].focus() - expect(document.activeElement).toBe(eligibleButtons[0]) - - // Test arrow key navigation - await user.keyboard('{ArrowRight}') - expect(document.activeElement).toBe(eligibleButtons[1]) - - await user.keyboard('{ArrowLeft}') - expect(document.activeElement).toBe(eligibleButtons[0]) - - // Test Home/End navigation - await user.keyboard('{End}') - expect(document.activeElement).toBe(eligibleButtons[eligibleButtons.length - 1]) - - await user.keyboard('{Home}') - expect(document.activeElement).toBe(eligibleButtons[0]) - }) - - it('should skip non-eligible plans during keyboard navigation', async () => { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) - render() - - const radioButtons = screen.getAllByRole('radio') - const eligibleButtons = radioButtons.filter(button => - button.getAttribute('aria-disabled') !== 'true' - ) - - // Navigate through only eligible plans - eligibleButtons[0].focus() - await user.keyboard('{ArrowRight}') - - // Should skip disabled plan and go to next eligible - expect(document.activeElement).toBe(eligibleButtons[1]) - expect(document.activeElement).not.toBe(radioButtons[2]) // Disabled plan - }) -}) -``` - -#### EligibilityModal Tests -```typescript -import { render, screen, fireEvent } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import EligibilityPlansButtons from 'Widgets/EligibilityModal/components/EligibilityPlansButtons' - -describe('EligibilityModal Accessibility Tests', () => { - const defaultProps = { - eligibilityPlans: [ - { installments_count: 1, eligible: true }, - { installments_count: 3, eligible: true }, - { installments_count: 4, eligible: true } - ], - currentPlanIndex: 0, - setCurrentPlanIndex: jest.fn() - } - - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should handle keyboard navigation with automatic focus', async () => { - const user = userEvent.setup() - render() - - const buttons = screen.getAllByRole('button') - - // Focus first button - buttons[0].focus() - expect(document.activeElement).toBe(buttons[0]) - - // Test navigation - await user.keyboard('{ArrowRight}') - expect(defaultProps.setCurrentPlanIndex).toHaveBeenCalledWith(1) - - await user.keyboard('{Home}') - expect(defaultProps.setCurrentPlanIndex).toHaveBeenCalledWith(0) - - await user.keyboard('{End}') - expect(defaultProps.setCurrentPlanIndex).toHaveBeenCalledWith(2) - }) - - it('should have proper ARIA attributes', () => { - render() - - const group = screen.getByRole('group') - expect(group).toHaveAttribute('aria-labelledby', 'payment-plans-title') - - const buttons = screen.getAllByRole('button') - buttons.forEach((button, index) => { - expect(button).toHaveAttribute('aria-pressed') - expect(button).toHaveAttribute('aria-describedby', 'payment-info') - expect(button).toHaveAttribute('aria-label') - - if (index === defaultProps.currentPlanIndex) { - expect(button).toHaveAttribute('aria-current', 'true') - } - }) - }) -}) -``` - -## Custom Hook Testing - -### useAnnounceText Hook Tests -```typescript -import { renderHook, act } from '@testing-library/react' -import { useAnnounceText } from 'hooks/useAnnounceText' - -describe('useAnnounceText Hook Tests', () => { - beforeEach(() => { - jest.useFakeTimers() - }) - - afterEach(() => { - jest.runOnlyPendingTimers() - jest.useRealTimers() - }) - - it('should initialize with empty announcement text', () => { - const { result } = renderHook(() => useAnnounceText()) - expect(result.current.announceText).toBe('') - }) - - it('should announce text and clear after default delay', () => { - const { result } = renderHook(() => useAnnounceText()) - - act(() => { - result.current.announce('Test announcement') - }) - - expect(result.current.announceText).toBe('Test announcement') - - act(() => { - jest.advanceTimersByTime(1000) - }) - - expect(result.current.announceText).toBe('') - }) - - it('should support custom delay timing', () => { - const { result } = renderHook(() => useAnnounceText()) - - act(() => { - result.current.announce('Custom delay test', 2500) - }) - - expect(result.current.announceText).toBe('Custom delay test') - - // Should still be present before custom delay - act(() => { - jest.advanceTimersByTime(2000) - }) - expect(result.current.announceText).toBe('Custom delay test') - - // Should be cleared after custom delay - act(() => { - jest.advanceTimersByTime(500) - }) - expect(result.current.announceText).toBe('') - }) - - it('should handle multiple consecutive announcements', () => { - const { result } = renderHook(() => useAnnounceText()) - - act(() => { - result.current.announce('First announcement') - }) - expect(result.current.announceText).toBe('First announcement') - - act(() => { - result.current.announce('Second announcement') - }) - expect(result.current.announceText).toBe('Second announcement') - }) - - it('should clear announcement immediately when requested', () => { - const { result } = renderHook(() => useAnnounceText()) - - act(() => { - result.current.announce('Test announcement') - }) - expect(result.current.announceText).toBe('Test announcement') - - act(() => { - result.current.clearAnnouncement() - }) - expect(result.current.announceText).toBe('') - }) - - it('should maintain stable function references', () => { - const { result, rerender } = renderHook(() => useAnnounceText()) - - const firstAnnounce = result.current.announce - const firstClear = result.current.clearAnnouncement - - rerender() - - expect(result.current.announce).toBe(firstAnnounce) - expect(result.current.clearAnnouncement).toBe(firstClear) - }) -}) -``` - -## Manual Testing - -### Keyboard Navigation Testing - -#### PaymentPlans Widget Navigation -**Test Steps:** -1. **Tab Navigation** - - [ ] Tab to widget button - - [ ] Focus is clearly visible - - [ ] Tab moves to next eligible plan button - - [ ] Tab skips disabled/ineligible plans - -2. **Arrow Key Navigation** - - [ ] Arrow Left: Navigate to previous eligible plan - - [ ] Arrow Right: Navigate to next eligible plan - - [ ] Navigation stops at boundaries (no wrapping) - - [ ] Focus follows selection visually - - [ ] Only eligible plans receive focus - -3. **Home/End Navigation** - - [ ] Home: Jump to first eligible plan - - [ ] End: Jump to last eligible plan - - [ ] Focus moves correctly - -4. **Enter/Space Activation** - - [ ] Enter opens modal from main widget - - [ ] Space opens modal from main widget - - [ ] Modal focus is properly trapped - -#### EligibilityModal Navigation -**Test Steps:** -1. **Plan Selection Navigation** - - [ ] Arrow keys navigate between all plans - - [ ] Home/End jump to first/last plan - - [ ] Focus follows navigation automatically - - [ ] Visual selection updates with keyboard - -2. **Modal Controls** - - [ ] ESC key closes modal - - [ ] Focus returns to trigger element - - [ ] Background scrolling is prevented - -### Screen Reader Compatibility - -#### NVDA Testing (Windows) -**Test Steps:** -1. **Plan Navigation** - - [ ] Plans announced as "radio button" - - [ ] Current selection state announced - - [ ] Plan descriptions read correctly - - [ ] Group role announced as "radiogroup" - -2. **Live Announcements** - - [ ] Plan changes announced automatically - - [ ] Announcements don't overlap - - [ ] Clear, contextual messaging - -#### VoiceOver Testing (macOS) -**Test Steps:** -1. **Rotor Navigation** - - [ ] Plans appear in Form Controls rotor - - [ ] Landmarks properly identified - - [ ] Headings navigate correctly - -2. **Interaction** - - [ ] VO + Space activates buttons - - [ ] Arrow keys work with VO navigation - - [ ] Status changes announced - -#### JAWS Testing (Windows) -**Test Steps:** -1. **Virtual Cursor** - - [ ] All content accessible with virtual cursor - - [ ] Interactive elements properly identified - - [ ] ARIA attributes read correctly - -2. **Forms Mode** - - [ ] Automatic forms mode activation - - [ ] Proper navigation within radiogroup - - [ ] Modal dialog announced correctly - -## Keyboard Navigation Testing - -### Enhanced Testing Checklist - -#### Focus Management -- [ ] **Visible Focus**: All focusable elements have clear focus indicators -- [ ] **Focus Order**: Logical tab sequence through all interactive elements -- [ ] **Focus Trapping**: Modal properly traps focus within dialog -- [ ] **Focus Restoration**: Focus returns to trigger after modal closes -- [ ] **Programmatic Focus**: Arrow navigation moves both selection and focus - -#### Navigation Patterns -- [ ] **Radiogroup Pattern**: Proper ARIA radiogroup implementation -- [ ] **Arrow Navigation**: Left/Right arrows navigate between options -- [ ] **Boundary Handling**: Navigation stops at first/last items -- [ ] **Disabled Items**: Keyboard navigation skips disabled options -- [ ] **Home/End Keys**: Quick navigation to first/last items - -#### Advanced Interactions -- [ ] **Multiple Modalities**: Mouse, touch, and keyboard all work -- [ ] **State Synchronization**: Visual state matches programmatic state -- [ ] **Error Handling**: Graceful handling of edge cases -- [ ] **Performance**: Smooth navigation without delays - -## Screen Reader Testing - -### Comprehensive Screen Reader Testing - -#### Content Structure -- [ ] **Headings**: Proper heading hierarchy and navigation -- [ ] **Landmarks**: Main, navigation, and dialog landmarks -- [ ] **Lists**: Payment options properly structured -- [ ] **Tables**: Any tabular data properly marked up - -#### Interactive Elements -- [ ] **Buttons**: Purpose and state clearly communicated -- [ ] **Form Controls**: Labels, instructions, and validation -- [ ] **Links**: Descriptive link text and context -- [ ] **Custom Controls**: ARIA implementation working correctly - -#### Dynamic Content -- [ ] **Live Regions**: Plan changes announced appropriately -- [ ] **State Changes**: Selection changes communicated -- [ ] **Error Messages**: Errors announced when they occur -- [ ] **Loading States**: Loading/busy states communicated - -## RGAA Compliance Testing - -### Critical RGAA Criteria - -#### Criterion 7.3 - Focus Management -- [ ] **7.3.1**: Focus visible for all interactive elements -- [ ] **7.3.2**: Focus doesn't disappear during interactions -- [ ] **7.3.3**: Focus order is logical and predictable - -#### Criterion 7.4 - Status Messages -- [ ] **7.4.1**: Status messages announced to assistive technologies -- [ ] **7.4.2**: Status changes communicated appropriately -- [ ] **7.4.3**: Live regions properly implemented - -#### Criterion 12.8 - Tab Order -- [ ] **12.8.1**: Tab order follows visual order -- [ ] **12.8.2**: Tab order is logical within components -- [ ] **12.8.3**: Tab order handles dynamic content correctly - -### RGAA Testing Tools - -#### Automated Validation -```bash -# Install RGAA testing tools -npm install --save-dev @axe-core/react axe-core - -# Run RGAA-specific tests -npm run test:a11y:rgaa -``` - -#### Manual Validation Checklist -- [ ] **Navigation**: All content accessible via keyboard -- [ ] **Information**: No information conveyed by color alone -- [ ] **Structure**: Proper heading and landmark structure -- [ ] **Forms**: All form elements properly labeled -- [ ] **Scripts**: All script functionality accessible - -## Testing Tools - -### Automated Testing Tools -- **jest-axe**: WCAG compliance testing in Jest -- **@testing-library/react**: Component testing with accessibility in mind -- **@testing-library/user-event**: Realistic user interaction simulation -- **axe-core**: Core accessibility testing engine - -### Manual Testing Tools -- **NVDA**: Free Windows screen reader -- **VoiceOver**: Built-in macOS screen reader -- **JAWS**: Professional Windows screen reader -- **Dragon**: Voice recognition software testing -- **Keyboard only**: No mouse testing -- **High contrast mode**: Windows high contrast testing -- **Zoom testing**: 200% zoom level verification - -### Browser Extensions -- **axe DevTools**: Browser extension for quick accessibility checks -- **Lighthouse**: Built-in Chrome accessibility auditing -- **WAVE**: Web accessibility evaluation tool -- **Color Contrast Analyser**: Color contrast verification - -## Continuous Integration - -### Automated Testing Pipeline -```yaml -# .github/workflows/accessibility.yml -name: Accessibility Testing -on: [push, pull_request] - -jobs: - accessibility: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: '18' - - - name: Install dependencies - run: npm ci - - - name: Run accessibility tests - run: npm run test:a11y - - - name: Run custom hook tests - run: npm test -- --testPathPattern=useAnnounceText - - - name: Build and test - run: npm run build -``` - -### Testing Scripts -```json -{ - "scripts": { - "test:a11y": "jest --testNamePattern='Accessibility'", - "test:hooks": "jest --testPathPattern='hooks/'", - "test:keyboard": "jest --testNamePattern='keyboard'", - "test:screen-reader": "jest --testNamePattern='screen reader'" - } -} -``` - -### Quality Gates -- [ ] **Zero accessibility violations**: All axe-core tests pass -- [ ] **Keyboard navigation**: Manual keyboard testing completed -- [ ] **Screen reader testing**: At least one screen reader tested -- [ ] **RGAA compliance**: Critical criteria verified -- [ ] **Custom hook testing**: All accessibility hooks tested - ---- - -**Last Updated**: August 2025 -**Version**: 3.1.1 -**Testing Framework**: Jest + React Testing Library -**Accessibility Engine**: axe-core 4.x diff --git a/accessibility/RGAA_AUDIT_REPORT.md b/accessibility/RGAA_AUDIT_REPORT.md deleted file mode 100644 index a741f08..0000000 --- a/accessibility/RGAA_AUDIT_REPORT.md +++ /dev/null @@ -1,406 +0,0 @@ -# RGAA 4.1 Compliance Audit Report - -## Executive Summary - -**Project**: Alma Payment Widgets -**Audit Date**: August 2025 -**Standards**: RGAA 4.1 (French Accessibility Guidelines) -**Compliance Level**: AA -**Overall Status**: ✅ **FULLY COMPLIANT** - -## Recent Enhancements - -**Major Improvements Since Last Audit**: -- ✅ **Enhanced keyboard navigation** with proper focus management -- ✅ **Custom useAnnounceText hook** for improved screen reader announcements -- ✅ **Programmatic focus control** using refs for seamless navigation -- ✅ **Bidirectional arrow key navigation** with boundary handling -- ✅ **Home/End key support** for quick navigation -- ✅ **Improved accessibility testing** with comprehensive test coverage - -## Audit Results Overview - -| Category | Criteria Tested | Compliant | Non-Compliant | Not Applicable | -|----------|----------------|-----------|---------------|----------------| -| **Images** | 8 | 8 ✅ | 0 | 0 | -| **Frames** | 2 | 0 | 0 | 2 | -| **Colors** | 4 | 4 ✅ | 0 | 0 | -| **Multimedia** | 7 | 0 | 0 | 7 | -| **Tables** | 6 | 0 | 0 | 6 | -| **Links** | 5 | 5 ✅ | 0 | 0 | -| **Scripts** | 7 | 7 ✅ | 0 | 0 | -| **Mandatory Elements** | 8 | 8 ✅ | 0 | 0 | -| **Information Structure** | 10 | 10 ✅ | 0 | 0 | -| **Presentation** | 10 | 10 ✅ | 0 | 0 | -| **Forms** | 11 | 11 ✅ | 0 | 0 | -| **Navigation** | 5 | 5 ✅ | 0 | 0 | -| **Consultation** | 4 | 4 ✅ | 0 | 0 | - -**Total Applicable Criteria**: 53 -**Compliant**: 53 ✅ -**Compliance Rate**: 100% - -## Detailed Compliance Analysis - -### 1. Images (Critère 1.1 to 1.9) - -#### 1.1 - Alternative Text ✅ FULLY COMPLIANT -**Enhanced Implementation**: All informative images have comprehensive alternative text -```typescript -// Payment card icons with descriptive labels - - {/* SVG content */} - - -// Company logo with proper identification - - -// Decorative icons properly hidden -
      -``` - -**Test Results**: -- ✅ Radiogroup pattern correctly implemented -- ✅ All form controls properly labeled -- ✅ Group labels clearly identify purpose -- ✅ Related controls properly grouped - -### 12. Navigation (Critère 12.1 to 12.11) - -#### 12.8 - Tab Order ✅ ENHANCED COMPLIANCE -**Enhanced Implementation**: Improved tab order management -```typescript -// Proper tabindex management for disabled states -tabIndex={plan.eligible ? 0 : -1} - -// Skip links for quick navigation - -``` - -**Test Results**: -- ✅ Tab order follows visual order -- ✅ No keyboard traps (except intentional modal trapping) -- ✅ Disabled elements properly excluded from tab sequence -- ✅ Skip links provide efficient navigation - -#### 12.9 - Keyboard Shortcuts ✅ ENHANCED COMPLIANCE -**Enhanced Implementation**: Comprehensive keyboard navigation -- **Arrow keys**: Navigate between payment plans -- **Home/End**: Jump to first/last options -- **Enter/Space**: Activate buttons and open modal -- **Escape**: Close modal and return focus -- **Tab**: Standard focus navigation - -**Test Results**: -- ✅ No conflicts with assistive technology shortcuts -- ✅ All functionality accessible via keyboard -- ✅ Keyboard shortcuts work consistently -- ✅ Custom shortcuts don't override system shortcuts - -## Testing Methodology - -### Automated Testing -```typescript -// Enhanced accessibility testing suite -describe('RGAA Compliance Tests', () => { - it('should have no axe violations', async () => { - const { container } = render() - const results = await axe(container) - expect(results).toHaveNoViolations() - }) - - it('should support custom hook functionality', () => { - const { result } = renderHook(() => useAnnounceText()) - - act(() => { - result.current.announce('Test announcement') - }) - - expect(result.current.announceText).toBe('Test announcement') - }) -}) -``` - -### Manual Testing -- **Screen Reader Testing**: NVDA, JAWS, VoiceOver -- **Keyboard Navigation**: Complete keyboard-only testing -- **High Contrast**: Windows High Contrast mode -- **Zoom Testing**: 200% zoom verification -- **Focus Management**: Visual focus indicator testing - -### User Testing -- **Real Users**: Testing with actual screen reader users -- **Usability**: Task completion and satisfaction metrics -- **Feedback Integration**: Continuous improvement based on user input - -## Compliance Statement - -The Alma Payment Widgets project **fully complies** with RGAA 4.1 standards at AA level. All applicable criteria have been implemented and tested. - -### Key Strengths -- ✅ **100% automated test pass rate** with axe-core -- ✅ **Enhanced keyboard navigation** with focus management -- ✅ **Custom accessibility hooks** for reusable functionality -- ✅ **Comprehensive screen reader support** across major platforms -- ✅ **Robust testing suite** with automated and manual verification - -### Continuous Monitoring -- Regular automated testing in CI/CD pipeline -- Quarterly manual accessibility audits -- User feedback integration and response -- Accessibility feature development tracking - ---- - -**Audit Conducted By**: Accessibility Team -**Next Review Date**: November 2025 -**Certification**: RGAA 4.1 AA Compliant -**Version**: 3.1.1 - -**Contact**: For accessibility questions or issues, please refer to the accessibility implementation documentation or submit an issue via the project repository. From 665db36d178d337122bc8f1db258efb018e08893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 15:50:13 +0200 Subject: [PATCH 11/13] fix(import): remove unused import --- src/Widgets/EligibilityModal/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Widgets/EligibilityModal/index.tsx b/src/Widgets/EligibilityModal/index.tsx index 71d5ab1..9932bf5 100644 --- a/src/Widgets/EligibilityModal/index.tsx +++ b/src/Widgets/EligibilityModal/index.tsx @@ -5,7 +5,6 @@ import { useMediaQuery } from 'react-responsive' import { Card, EligibilityPlan, statusResponse } from '@/types' import { desktopWidth, isP1X } from '@/utils' -import TotalBlock from 'components/Installments/TotalBlock' import { LoadingIndicator } from 'components/LoadingIndicator/LoadingIndicator' import Modal from 'components/Modal' import SkipLinks from 'components/SkipLinks' From 50176e512cfaa889286232861f4a55db31530144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 15:55:01 +0200 Subject: [PATCH 12/13] fix(lint): remove unused variable --- src/hooks/useButtonAnimation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useButtonAnimation.test.ts b/src/hooks/useButtonAnimation.test.ts index 72f77ba..723437e 100644 --- a/src/hooks/useButtonAnimation.test.ts +++ b/src/hooks/useButtonAnimation.test.ts @@ -272,7 +272,7 @@ describe('useButtonAnimation', () => { it('should handle component unmounting during animation', () => { const iterateValues = [100, 200, 300] - const { result, unmount } = renderHook(() => useButtonAnimation(iterateValues, 1000)) + const { unmount } = renderHook(() => useButtonAnimation(iterateValues, 1000)) // Unmount component unmount() From d430aec4c937fee39313c20712d718accb0000d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?France=20B=C3=A9rut?= Date: Fri, 29 Aug 2025 16:13:41 +0200 Subject: [PATCH 13/13] chore(refactor): create a dedicated hook for animation instructions --- src/Widgets/PaymentPlans/index.tsx | 32 +-- src/hooks/useAnimationInstructions.test.ts | 260 +++++++++++++++++++++ src/hooks/useAnimationInstructions.ts | 63 +++++ 3 files changed, 330 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useAnimationInstructions.test.ts create mode 100644 src/hooks/useAnimationInstructions.ts diff --git a/src/Widgets/PaymentPlans/index.tsx b/src/Widgets/PaymentPlans/index.tsx index 6a2baa5..1679234 100644 --- a/src/Widgets/PaymentPlans/index.tsx +++ b/src/Widgets/PaymentPlans/index.tsx @@ -6,6 +6,7 @@ import { useIntl } from 'react-intl' import { ApiConfig, Card, ConfigPlan, statusResponse } from '@/types' import { AlmaLogo } from 'assets/almaLogo' import Loader from 'components/Loader' +import { useAnimationInstructions } from 'hooks/useAnimationInstructions' import { useAnnounceText } from 'hooks/useAnnounceText' import useButtonAnimation from 'hooks/useButtonAnimation' import useFetchEligibility from 'hooks/useFetchEligibility' @@ -166,31 +167,12 @@ const PaymentPlanWidget: FunctionComponent = ({ }, [status]) // Announce animation control instructions to screen readers on initial load - useEffect(() => { - if ( - status === statusResponse.SUCCESS && - !hasUserInteracted && - eligiblePlans.length > 1 && - // If transitionDelay is -1, animation is disabled, so no need to announce instructions - transitionDelay !== -1 - ) { - const instructionText = intl.formatMessage({ - id: 'accessibility.animation-control-instructions', - defaultMessage: - "Animation automatique des plans de paiement active. Survolez ou naviguez avec les flèches pour arrêter l'animation.", - }) - - // Delay the announcement to avoid conflict with initial plan announcements - const timer = setTimeout(() => { - announce(instructionText, 2000) - }, 1500) - - return () => clearTimeout(timer) - } - - // Return undefined if condition is not met (no cleanup needed) - return undefined - }, [status, hasUserInteracted, eligiblePlans.length, intl, announce, transitionDelay]) + useAnimationInstructions({ + status, + hasUserInteracted, + eligiblePlansCount: eligiblePlans.length, + transitionDelay, + }) /** * Handle user hover interaction - stops animation permanently diff --git a/src/hooks/useAnimationInstructions.test.ts b/src/hooks/useAnimationInstructions.test.ts new file mode 100644 index 0000000..50448ed --- /dev/null +++ b/src/hooks/useAnimationInstructions.test.ts @@ -0,0 +1,260 @@ +import { renderHook } from '@testing-library/react' +import { useIntl } from 'react-intl' + +import { statusResponse } from '@/types' +import { useAnimationInstructions } from 'hooks/useAnimationInstructions' +import { useAnnounceText } from 'hooks/useAnnounceText' + +// Mock dependencies +jest.mock('react-intl', () => ({ + useIntl: jest.fn(), +})) + +jest.mock('hooks/useAnnounceText', () => ({ + useAnnounceText: jest.fn(), +})) + +describe('useAnimationInstructions', () => { + const mockFormatMessage = jest.fn() + const mockAnnounce = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + + // Setup mocks + ;(useIntl as jest.Mock).mockReturnValue({ + formatMessage: mockFormatMessage, + }) + ;(useAnnounceText as jest.Mock).mockReturnValue({ + announce: mockAnnounce, + }) + + mockFormatMessage.mockReturnValue('Animation instructions text') + }) + + afterEach(() => { + jest.useRealTimers() + }) + + describe('when conditions are met for announcing instructions', () => { + const defaultProps = { + status: statusResponse.SUCCESS, + hasUserInteracted: false, + eligiblePlansCount: 2, + transitionDelay: 5000, + } + + it('should announce instructions after delay when all conditions are met', () => { + renderHook(() => useAnimationInstructions(defaultProps)) + + // Verify formatMessage is called with correct parameters + expect(mockFormatMessage).toHaveBeenCalledWith({ + id: 'accessibility.animation-control-instructions', + defaultMessage: + "Animation automatique des plans de paiement active. Survolez ou naviguez avec les flèches pour arrêter l'animation.", + }) + + // Initially, announce should not be called + expect(mockAnnounce).not.toHaveBeenCalled() + + // Fast forward to the announcement delay + jest.advanceTimersByTime(1500) + + // Now announce should be called with the formatted message + expect(mockAnnounce).toHaveBeenCalledWith('Animation instructions text', 2000) + }) + + it('should clear timeout on unmount', () => { + const { unmount } = renderHook(() => useAnimationInstructions(defaultProps)) + + // Start the timer + jest.advanceTimersByTime(1000) + + // Unmount before timer completes + unmount() + + // Continue to when timer would have completed + jest.advanceTimersByTime(1000) + + // Announce should not be called since component was unmounted + expect(mockAnnounce).not.toHaveBeenCalled() + }) + + it('should clear previous timeout when dependencies change', () => { + const { rerender } = renderHook((props) => useAnimationInstructions(props), { + initialProps: defaultProps, + }) + + // Start first timer + jest.advanceTimersByTime(1000) + + // Change props to retrigger effect + rerender({ ...defaultProps, eligiblePlansCount: 3 }) + + // Advance to where first timer would have completed + jest.advanceTimersByTime(500) + + // Should not announce yet (first timer was cleared) + expect(mockAnnounce).not.toHaveBeenCalled() + + // Advance to where second timer completes + jest.advanceTimersByTime(1000) + + // Should announce once with the new message + expect(mockAnnounce).toHaveBeenCalledTimes(1) + expect(mockAnnounce).toHaveBeenCalledWith('Animation instructions text', 2000) + }) + }) + + describe('when conditions are not met', () => { + it('should not announce when status is not SUCCESS', () => { + renderHook(() => + useAnimationInstructions({ + status: statusResponse.PENDING, + hasUserInteracted: false, + eligiblePlansCount: 2, + transitionDelay: 5000, + }), + ) + + jest.advanceTimersByTime(2000) + + expect(mockFormatMessage).not.toHaveBeenCalled() + expect(mockAnnounce).not.toHaveBeenCalled() + }) + + it('should not announce when user has already interacted', () => { + renderHook(() => + useAnimationInstructions({ + status: statusResponse.SUCCESS, + hasUserInteracted: true, + eligiblePlansCount: 2, + transitionDelay: 5000, + }), + ) + + jest.advanceTimersByTime(2000) + + expect(mockFormatMessage).not.toHaveBeenCalled() + expect(mockAnnounce).not.toHaveBeenCalled() + }) + + it('should not announce when there is only one eligible plan', () => { + renderHook(() => + useAnimationInstructions({ + status: statusResponse.SUCCESS, + hasUserInteracted: false, + eligiblePlansCount: 1, + transitionDelay: 5000, + }), + ) + + jest.advanceTimersByTime(2000) + + expect(mockFormatMessage).not.toHaveBeenCalled() + expect(mockAnnounce).not.toHaveBeenCalled() + }) + + it('should not announce when animation is disabled (transitionDelay is -1)', () => { + renderHook(() => + useAnimationInstructions({ + status: statusResponse.SUCCESS, + hasUserInteracted: false, + eligiblePlansCount: 2, + transitionDelay: -1, + }), + ) + + jest.advanceTimersByTime(2000) + + expect(mockFormatMessage).not.toHaveBeenCalled() + expect(mockAnnounce).not.toHaveBeenCalled() + }) + + it('should announce when transitionDelay is undefined (animation enabled)', () => { + renderHook(() => + useAnimationInstructions({ + status: statusResponse.SUCCESS, + hasUserInteracted: false, + eligiblePlansCount: 2, + transitionDelay: undefined, + }), + ) + + jest.advanceTimersByTime(1500) + + // With undefined transitionDelay, animation should be enabled, so announcement should happen + expect(mockFormatMessage).toHaveBeenCalled() + expect(mockAnnounce).toHaveBeenCalledWith('Animation instructions text', 2000) + }) + }) + + describe('timing behavior', () => { + const defaultProps = { + status: statusResponse.SUCCESS, + hasUserInteracted: false, + eligiblePlansCount: 2, + transitionDelay: 5000, + } + + it('should use correct announcement delay (1500ms)', () => { + renderHook(() => useAnimationInstructions(defaultProps)) + + // Should not announce before delay + jest.advanceTimersByTime(1400) + expect(mockAnnounce).not.toHaveBeenCalled() + + // Should announce after delay + jest.advanceTimersByTime(100) + expect(mockAnnounce).toHaveBeenCalled() + }) + + it('should use correct announcement duration (2000ms)', () => { + renderHook(() => useAnimationInstructions(defaultProps)) + + jest.advanceTimersByTime(1500) + + expect(mockAnnounce).toHaveBeenCalledWith('Animation instructions text', 2000) + }) + }) + + describe('integration with dependencies', () => { + it('should call useIntl and useAnnounceText hooks', () => { + renderHook(() => + useAnimationInstructions({ + status: statusResponse.SUCCESS, + hasUserInteracted: false, + eligiblePlansCount: 2, + transitionDelay: 5000, + }), + ) + + expect(useIntl).toHaveBeenCalled() + expect(useAnnounceText).toHaveBeenCalled() + }) + + it('should handle formatMessage errors gracefully', () => { + // Mock console.error to avoid displaying the error in test output + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + mockFormatMessage.mockImplementation(() => { + throw new Error('Translation error') + }) + + expect(() => { + renderHook(() => + useAnimationInstructions({ + status: statusResponse.SUCCESS, + hasUserInteracted: false, + eligiblePlansCount: 2, + transitionDelay: 5000, + }), + ) + }).toThrow('Translation error') + + // Restore console.error + consoleSpy.mockRestore() + }) + }) +}) diff --git a/src/hooks/useAnimationInstructions.ts b/src/hooks/useAnimationInstructions.ts new file mode 100644 index 0000000..a46a242 --- /dev/null +++ b/src/hooks/useAnimationInstructions.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react' + +import { useIntl } from 'react-intl' + +import { statusResponse } from '@/types' +import { useAnnounceText } from 'hooks/useAnnounceText' + +type UseAnimationInstructionsProps = { + status: statusResponse + hasUserInteracted: boolean + eligiblePlansCount: number + transitionDelay?: number +} + +/** + * Custom hook for managing accessibility announcements about animation control instructions + * + * Announces instructions to screen readers about how to control automatic plan animation + * when the widget is first loaded and animation is active. + * + * @param status - Current API response status + * @param hasUserInteracted - Whether user has manually interacted with plans + * @param eligiblePlansCount - Number of eligible payment plans + * @param transitionDelay - Animation transition delay (-1 means disabled) + * + * @returns void - This hook manages side effects only + */ +export const useAnimationInstructions = ({ + status, + hasUserInteracted, + eligiblePlansCount, + transitionDelay, +}: UseAnimationInstructionsProps): void => { + const intl = useIntl() + const { announce } = useAnnounceText() + + useEffect(() => { + const shouldAnnounceInstructions = + status === statusResponse.SUCCESS && + !hasUserInteracted && + eligiblePlansCount > 1 && + // If transitionDelay is -1, animation is disabled, so no need to announce instructions + transitionDelay !== -1 + + if (shouldAnnounceInstructions) { + const instructionText = intl.formatMessage({ + id: 'accessibility.animation-control-instructions', + defaultMessage: + "Animation automatique des plans de paiement active. Survolez ou naviguez avec les flèches pour arrêter l'animation.", + }) + + // Delay the announcement to avoid conflict with initial plan announcements + const timer = setTimeout(() => { + announce(instructionText, 2000) + }, 1500) + + return () => clearTimeout(timer) + } + + // Return undefined if condition is not met (no cleanup needed) + return undefined + }, [status, hasUserInteracted, eligiblePlansCount, intl, announce, transitionDelay]) +}