diff --git a/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css b/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css
index 83b86cd..c9550a4 100644
--- a/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css
+++ b/src/Widgets/EligibilityModal/components/Schedule/Schedule.module.css
@@ -1,6 +1,15 @@
+.scheduleContainer {
+ padding: 6px;
+}
+
+.scheduleContainer:focus,
+.scheduleContainer:focus-visible {
+ outline: 1px auto var(--alma-orange);
+}
+
.schedule {
padding: 0 24px;
- margin-bottom: 16px;
+ margin: 6px 0;
font-family: 'Venn', sans-serif;
}
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 660e2a2..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'
@@ -76,8 +75,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',
+ }}
>
= ({
>
)}
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;
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/__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/Widgets/PaymentPlans/index.tsx b/src/Widgets/PaymentPlans/index.tsx
index 886e9ad..1679234 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'
@@ -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'
@@ -34,9 +35,17 @@ type Props = {
onModalClose?: (event: React.MouseEvent | React.KeyboardEvent) => void
}
-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,23 @@ 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
- 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,39 +95,106 @@ const PaymentPlanWidget: FunctionComponent = ({
onModalClose?.(event)
}
- const eligiblePlanKeys = eligibilityPlans.reduce(
- (acc, plan, index) => (plan.eligible ? [...acc, index] : acc),
- [],
+ // 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(
+ () =>
+ eligibilityPlans.reduce(
+ (acc, plan, index) => (plan.eligible ? [...acc, index] : acc),
+ [],
+ ),
+ [eligibilityPlans],
)
/**
- * If merchant specify a suggestedPaymentPlan and no transition, we set a very long transition delay.
+ * Calculate the appropriate transition time based on merchant configuration
+ * 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 = (): number => {
- if (isTransitionSpecified) {
- return transitionDelay ?? DEFAULT_TRANSITION_TIME
- }
- if (isSuggestedPaymentPlanSpecified) {
- return VERY_LONG_TIME_IN_MS
+ const realTransitionTime = useMemo((): number => {
+ if (isSuggestedPaymentPlanSpecified && !transitionDelay) {
+ return -1 // Disable animation
}
- return DEFAULT_TRANSITION_TIME
- }
+ return transitionDelay ?? DEFAULT_TRANSITION_TIME
+ }, [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])
+
+ // Announce animation control instructions to screen readers on initial load
+ useAnimationInstructions({
+ status,
+ hasUserInteracted,
+ eligiblePlansCount: eligiblePlans.length,
+ 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
* @param direction - 'next' or 'prev' for navigation direction
* @param currentIndex - Current plan index
*/
@@ -125,54 +213,31 @@ const PaymentPlanWidget: FunctionComponent = ({
}
const newPlanIndex = eligiblePlanKeys[newEligibleIndex]
+ // Mark as user interaction to stop animation permanently
+ setHasUserInteracted(true)
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') => {
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()
}
- // 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 +247,7 @@ const PaymentPlanWidget: FunctionComponent = ({
return index === -1 ? 0 : index
}
+ // Show loading state while fetching eligibility data
if (status === statusResponse.PENDING) {
return (
@@ -190,6 +256,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 +265,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 +280,7 @@ const PaymentPlanWidget: FunctionComponent = ({
return (
<>
+ {/* Main widget container - clickable to open modal */}
{
@@ -234,9 +306,21 @@ 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 */}
+
+ {/* Payment plans radio group for keyboard navigation */}
= ({
onHover(key)}
- onTouchStart={() => onHover(key)}
- onMouseLeave={onLeave}
- onBlur={onLeave}
- onTouchEnd={onLeave}
- onFocus={isEligible ? () => onHover(key) : undefined}
+ // Mouse/touch interactions for plan selection
+ onMouseEnter={() => handleUserHover(key)}
+ onTouchStart={() => handleUserHover(key)}
+ onMouseLeave={handleUserLeave}
+ onBlur={handleUserLeave}
+ onTouchEnd={handleUserLeave}
+ // Focus handling for keyboard users
+ onFocus={isEligible ? () => handleUserHover(key) : undefined}
+ // Keyboard navigation between eligible plans
onKeyDown={(e) => {
if (!isEligible) return
@@ -277,6 +364,7 @@ const PaymentPlanWidget: FunctionComponent = ({
navigateToEdgePlan('last')
}
}}
+ // Click to select plan and open modal
onClick={(e) => {
e.stopPropagation()
if (isEligible) {
@@ -293,6 +381,7 @@ const PaymentPlanWidget: FunctionComponent = ({
[cx(s.notEligible, STATIC_CUSTOMISATION_CLASSES.notEligibleOption)]:
!isEligible,
})}
+ // Accessibility attributes for screen readers
role="radio"
aria-checked={isCurrent}
aria-describedby="payment-info-text"
@@ -310,7 +399,7 @@ const PaymentPlanWidget: FunctionComponent = ({
tabIndex={isEligible ? 0 : -1}
ref={(el) => {
buttonRefs.current[key] = el
- }} // Assign ref to button
+ }} // Assign ref for keyboard navigation focus management
>
{paymentPlanShorthandName(eligibilityPlan)}
@@ -318,6 +407,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}
diff --git a/src/assets/almaLogo.tsx b/src/assets/almaLogo.tsx
index 002536e..2b8ca53 100644
--- a/src/assets/almaLogo.tsx
+++ b/src/assets/almaLogo.tsx
@@ -21,6 +21,8 @@ export const AlmaLogo = ({ className, color = '#fa5022' }: Props) => {
id: 'accessibility.alma-logo.aria-label',
defaultMessage: 'Logo Alma - Solution de paiement en plusieurs fois',
})}
+ focusable={false}
+ aria-hidden
role="img"
>
{
)
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 }) => (
-
+
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])
+}
diff --git a/src/hooks/useButtonAnimation.test.ts b/src/hooks/useButtonAnimation.test.ts
new file mode 100644
index 0000000..723437e
--- /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 { 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 0b41b28..57333bc 100644
--- a/src/hooks/useButtonAnimation.ts
+++ b/src/hooks/useButtonAnimation.ts
@@ -9,15 +9,15 @@ 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' &&
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
@@ -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,
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",
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;