Skip to content

Commit 918a871

Browse files
authored
Rework structure not to impact third party sites but keep rgaa requirements (#203)
Uses neutral html tags with `roles` to keep the best practices for accessibility and screen readers but avoid injecting HTML structure that could impact third party websites that integrates our widget.
1 parent ed11690 commit 918a871

9 files changed

Lines changed: 407 additions & 225 deletions

File tree

accessibility/ACCESSIBILITY_DOCUMENTATION.md

Lines changed: 213 additions & 160 deletions
Large diffs are not rendered by default.

src/Widgets/EligibilityModal/DesktopModal/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ const DesktopModal: FC<Props> = ({ children, isSomePlanDeferred, cards, isCurren
2323
aria-modal="true"
2424
aria-labelledby="modal-title"
2525
>
26-
<aside className={cx([s.block, s.left, STATIC_CUSTOMISATION_CLASSES.leftSide])}>
26+
<div className={cx([s.block, s.left, STATIC_CUSTOMISATION_CLASSES.leftSide])}>
2727
<Title isSomePlanDeferred={isSomePlanDeferred} isCurrentPlanP1X={isCurrentPlanP1X} />
2828
<Info id="payment-info" isCurrentPlanP1X={isCurrentPlanP1X} />
2929
{cards && <Cards cards={cards} />}
3030
<AlmaLogo className={s.logo} width="75" />
31-
</aside>
31+
</div>
3232
<div className={cx(s.block, STATIC_CUSTOMISATION_CLASSES.rightSide)}>{children}</div>
3333
</div>
3434
)

src/Widgets/EligibilityModal/components/EligibilityPlansButtons/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ const EligibilityPlansButtons: FC<{
4545

4646
return (
4747
<div>
48-
<h5 id="payment-plans-title" className="sr-only">
48+
<div id="payment-plans-title" className="sr-only" role="heading" aria-level={2}>
4949
{intl.formatMessage({
5050
id: 'accessibility.payment-plans.section-title',
5151
defaultMessage: 'Options de paiement disponibles',
5252
})}
53-
</h5>
53+
</div>
5454
<div
5555
id={id}
5656
className={cx(s.buttons, STATIC_CUSTOMISATION_CLASSES.eligibilityOptions)}

src/Widgets/EligibilityModal/components/Info/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ const Info: FC<{ isCurrentPlanP1X: boolean; id?: string }> = ({ isCurrentPlanP1X
1919
aria-describedby="payment-info-description"
2020
tabIndex={-1}
2121
>
22-
<h5 id="payment-info-title" className="sr-only">
22+
<div id="payment-info-title" className="sr-only" role="heading" aria-level={2}>
2323
<FormattedMessage
2424
id="eligibility-modal.info-title"
2525
defaultMessage="Comment procéder au paiement"
2626
/>
27-
</h5>{' '}
27+
</div>{' '}
2828
<ol className={s.listContainer} id="payment-info-description">
2929
<li className={s.listItem}>
3030
<div className={cx(s.bullet, STATIC_CUSTOMISATION_CLASSES.bullet)}>1</div>

src/Widgets/EligibilityModal/components/Schedule/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ const Schedule: FC<{ currentPlan: EligibilityPlan; id?: string }> = ({ currentPl
1818
aria-describedby="payment-schedule-description"
1919
tabIndex={-1}
2020
>
21-
<h6 id="payment-schedule-title" className="sr-only">
21+
<div id="payment-schedule-title" className="sr-only" role="heading" aria-level={2}>
2222
<FormattedMessage
2323
id="accessibility.payment-schedule-title"
2424
defaultMessage="Calendrier de paiement"
2525
/>
26-
</h6>
26+
</div>
2727
<div id="payment-schedule-description">
2828
<ul
2929
className={cx(s.schedule, STATIC_CUSTOMISATION_CLASSES.scheduleDetails)}

src/Widgets/EligibilityModal/components/Title/index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ const Title: FC<{ isSomePlanDeferred: boolean; isCurrentPlanP1X: boolean }> = ({
1010
isSomePlanDeferred,
1111
isCurrentPlanP1X,
1212
}) => (
13-
<h5
13+
<div
1414
id="modal-title"
1515
className={cx(s.title, STATIC_CUSTOMISATION_CLASSES.title)}
1616
data-testid="modal-title-element"
17+
role="heading"
18+
aria-level={1}
1719
>
1820
{isSomePlanDeferred && (
1921
<FormattedMessage
@@ -33,7 +35,7 @@ const Title: FC<{ isSomePlanDeferred: boolean; isCurrentPlanP1X: boolean }> = ({
3335
defaultMessage="Payez en plusieurs fois par carte bancaire avec Alma."
3436
/>
3537
)}
36-
</h5>
38+
</div>
3739
)
3840

3941
export default Title

src/Widgets/EligibilityModal/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,18 @@ const EligibilityModal: FunctionComponent<Props> = ({
101101
)}
102102
{status === statusResponse.SUCCESS && eligiblePlans.length >= 1 && (
103103
<>
104-
<section aria-labelledby="payment-plans-title">
104+
<div role="region" aria-labelledby="payment-plans-title">
105105
<EligibilityPlansButtons
106106
id="payment-plans"
107107
eligibilityPlans={eligiblePlans}
108108
currentPlanIndex={currentPlanIndex}
109109
setCurrentPlanIndex={setCurrentPlanIndex}
110110
/>
111-
</section>
112-
<section className={s.scheduleArea} aria-labelledby="payment-schedule-title">
111+
</div>
112+
<div className={s.scheduleArea} role="region" aria-labelledby="payment-schedule-title">
113113
<div className={s.verticalLine} />
114114
<Schedule id="payment-schedule" currentPlan={currentPlan} />
115-
</section>
115+
</div>
116116
</>
117117
)}
118118
</ModalComponent>

src/Widgets/PaymentPlans/__tests__/AriaLandmarks.test.tsx

Lines changed: 165 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ const mockUseFetchEligibility = require('hooks/useFetchEligibility').default as
1919
typeof import('hooks/useFetchEligibility').default
2020
>
2121

22-
describe('PaymentPlans ARIA Landmarks (RGAA 12.6)', () => {
22+
describe('PaymentPlans ARIA Landmarks (RGAA 12.6) - Widget-Safe Structure', () => {
2323
beforeEach(() => {
2424
jest.clearAllMocks()
2525
mockUseFetchEligibility.mockImplementation(() => [mockButtonPlans, statusResponse.SUCCESS])
2626
})
2727

28-
it('should have proper landmark structure for RGAA 12.6 compliance', async () => {
28+
it('should have proper widget-safe landmark structure for RGAA compliance', async () => {
2929
render(
3030
<PaymentPlanWidget
3131
purchaseAmount={40000}
@@ -35,35 +35,50 @@ describe('PaymentPlans ARIA Landmarks (RGAA 12.6)', () => {
3535

3636
await screen.findByTestId('widget-container')
3737

38-
// Check widget container exists (now a simple div without aria-label)
38+
// Check widget container exists (neutral div without semantic interference)
3939
const widgetContainer = screen.getByTestId('widget-container')
4040
expect(widgetContainer).toBeInTheDocument()
41+
expect(widgetContainer.tagName).toBe('DIV')
42+
expect(widgetContainer).toHaveAttribute('id', 'alma-widget-payment-plans-main-container')
4143

42-
// Check section landmark exists at top level
43-
const sectionLandmark = screen.getByRole('region')
44-
expect(sectionLandmark).toBeInTheDocument()
45-
expect(sectionLandmark).toHaveAttribute('aria-labelledby', 'payment-plans-title')
44+
// Check both region landmarks exist with proper ARIA labeling
45+
const regionLandmarks = screen.getAllByRole('region')
46+
expect(regionLandmarks).toHaveLength(2)
4647

47-
// Check aside landmark exists at top level (not nested)
48-
const asideLandmark = screen.getByRole('complementary')
49-
expect(asideLandmark).toBeInTheDocument()
50-
expect(asideLandmark).toHaveAttribute('aria-labelledby', 'payment-info-title')
48+
// Payment plans region
49+
const paymentPlansRegion = screen.getByRole('region', {
50+
name: 'Options de paiement disponibles',
51+
})
52+
expect(paymentPlansRegion).toBeInTheDocument()
53+
expect(paymentPlansRegion).toHaveAttribute('aria-labelledby', 'payment-plans-title')
54+
55+
// Payment info region
56+
const paymentInfoRegion = screen.getByRole('region', {
57+
name: /Informations sur le plan de paiement/,
58+
})
59+
expect(paymentInfoRegion).toBeInTheDocument()
60+
expect(paymentInfoRegion).toHaveAttribute('aria-labelledby', 'payment-info-title')
5161

52-
// Verify aside is not nested inside section (fixes landmark-complementary-is-top-level)
53-
expect(sectionLandmark.contains(asideLandmark)).toBe(false)
62+
// Verify regions are siblings (not nested) for proper landmark structure
63+
expect(paymentPlansRegion.contains(paymentInfoRegion)).toBe(false)
64+
expect(paymentInfoRegion.contains(paymentPlansRegion)).toBe(false)
5465

55-
// Check screen reader only titles exist
66+
// Check ARIA-based heading structure (widget-safe)
5667
const paymentPlansTitle = document.getElementById('payment-plans-title')
5768
expect(paymentPlansTitle).toBeInTheDocument()
69+
expect(paymentPlansTitle).toHaveAttribute('role', 'heading')
70+
expect(paymentPlansTitle).toHaveAttribute('aria-level', '2')
5871
expect(paymentPlansTitle).toHaveClass('sr-only')
5972

6073
const paymentInfoTitle = document.getElementById('payment-info-title')
6174
expect(paymentInfoTitle).toBeInTheDocument()
75+
expect(paymentInfoTitle).toHaveAttribute('role', 'heading')
76+
expect(paymentInfoTitle).toHaveAttribute('aria-level', '2')
6277
expect(paymentInfoTitle).toHaveClass('sr-only')
6378
})
6479

65-
it('should have no accessibility violations with new landmark structure', async () => {
66-
const { container } = render(
80+
it('should have proper ARIA-based heading hierarchy for screen readers', async () => {
81+
render(
6782
<PaymentPlanWidget
6883
purchaseAmount={40000}
6984
apiData={{ domain: ApiMode.TEST, merchantId: '11gKoO333vEXacMNMUMUSc4c4g68g2Les4' }}
@@ -72,19 +87,53 @@ describe('PaymentPlans ARIA Landmarks (RGAA 12.6)', () => {
7287

7388
await screen.findByTestId('widget-container')
7489

75-
// Test accessibility with all rules including landmarks
76-
const results = await axe(container, {
77-
rules: {
78-
'landmark-one-main': { enabled: true },
79-
'landmark-unique': { enabled: true },
80-
region: { enabled: true },
81-
},
90+
// Check ARIA headings exist with proper levels
91+
const headings = screen.getAllByRole('heading', { level: 2 })
92+
expect(headings).toHaveLength(2)
93+
94+
const paymentPlansHeading = screen.getByRole('heading', {
95+
name: 'Options de paiement disponibles',
8296
})
97+
expect(paymentPlansHeading).toBeInTheDocument()
98+
expect(paymentPlansHeading).toHaveTextContent('Options de paiement disponibles')
8399

84-
expect(results).toHaveNoViolations()
100+
const paymentInfoHeading = screen.getByRole('heading', {
101+
name: /Informations sur le plan de paiement/,
102+
})
103+
expect(paymentInfoHeading).toBeInTheDocument()
104+
expect(paymentInfoHeading.textContent).toMatch(/Informations sur le plan de paiement/)
105+
106+
// Verify headings are properly hidden from visual display but accessible to screen readers
107+
expect(paymentPlansHeading).toHaveClass('sr-only')
108+
expect(paymentInfoHeading).toHaveClass('sr-only')
109+
})
110+
111+
it('should not inject semantic HTML elements that could interfere with host pages', async () => {
112+
const { container } = render(
113+
<PaymentPlanWidget
114+
purchaseAmount={40000}
115+
apiData={{ domain: ApiMode.TEST, merchantId: '11gKoO333vEXacMNMUMUSc4c4g68g2Les4' }}
116+
/>,
117+
)
118+
119+
await screen.findByTestId('widget-container')
120+
121+
// Verify no problematic semantic HTML tags are injected
122+
expect(container.querySelector('section')).not.toBeInTheDocument()
123+
expect(container.querySelector('aside')).not.toBeInTheDocument()
124+
expect(container.querySelector('article')).not.toBeInTheDocument()
125+
expect(container.querySelector('main')).not.toBeInTheDocument()
126+
expect(container.querySelector('header')).not.toBeInTheDocument()
127+
expect(container.querySelector('footer')).not.toBeInTheDocument()
128+
expect(container.querySelector('nav')).not.toBeInTheDocument()
129+
expect(container.querySelector('h1, h2, h3, h4, h5, h6')).not.toBeInTheDocument()
130+
131+
// Verify ARIA-based semantics are used instead
132+
expect(screen.getAllByRole('region')).toHaveLength(2)
133+
expect(screen.getAllByRole('heading')).toHaveLength(2)
85134
})
86135

87-
it('should have proper heading hierarchy for screen readers', async () => {
136+
it('should maintain accessibility with listbox pattern for payment options', async () => {
88137
render(
89138
<PaymentPlanWidget
90139
purchaseAmount={40000}
@@ -94,17 +143,86 @@ describe('PaymentPlans ARIA Landmarks (RGAA 12.6)', () => {
94143

95144
await screen.findByTestId('widget-container')
96145

97-
// Check heading hierarchy exists (h5 for main section, h6 for aside)
98-
const h5Heading = screen.getByRole('heading', { level: 5 })
99-
expect(h5Heading).toBeInTheDocument()
100-
expect(h5Heading).toHaveTextContent('Options de paiement disponibles')
146+
// Check listbox exists with proper labeling
147+
const listbox = screen.getByRole('listbox')
148+
expect(listbox).toBeInTheDocument()
149+
expect(listbox).toHaveAttribute('aria-label', 'Options de paiement disponibles')
150+
151+
// Check options exist within listbox
152+
const options = screen.getAllByRole('option')
153+
expect(options.length).toBeGreaterThan(0)
154+
155+
// Verify at least one option is selected
156+
const selectedOptions = options.filter(
157+
(option) => option.getAttribute('aria-selected') === 'true',
158+
)
159+
expect(selectedOptions).toHaveLength(1)
160+
161+
// Check all options have proper ARIA attributes
162+
options.forEach((option) => {
163+
expect(option).toHaveAttribute('role', 'option')
164+
expect(option).toHaveAttribute('aria-selected')
165+
expect(option).toHaveAttribute('aria-describedby', 'payment-info-text')
166+
expect(option).toHaveAttribute('aria-label')
167+
})
168+
})
169+
170+
it('should have no accessibility violations when using widget-safe structure', async () => {
171+
const { container } = render(
172+
<PaymentPlanWidget
173+
purchaseAmount={40000}
174+
apiData={{ domain: ApiMode.TEST, merchantId: '11gKoO333vEXacMNMUMUSc4c4g68g2Les4' }}
175+
/>,
176+
)
177+
178+
await screen.findByTestId('widget-container')
179+
180+
// Run axe accessibility tests
181+
const results = await axe(container)
182+
expect(results).toHaveNoViolations()
183+
184+
// Specifically check for heading hierarchy issues that could affect host pages
185+
const headingViolations = results.violations.filter(
186+
(violation) => violation.id.includes('heading') || violation.id.includes('landmark'),
187+
)
188+
expect(headingViolations).toHaveLength(0)
189+
})
190+
191+
it('should support multiple widget instances without conflicts', async () => {
192+
render(
193+
<div>
194+
<PaymentPlanWidget
195+
purchaseAmount={40000}
196+
apiData={{ domain: ApiMode.TEST, merchantId: '11gKoO333vEXacMNMUMUSc4c4g68g2Les4' }}
197+
/>
198+
<PaymentPlanWidget
199+
purchaseAmount={50000}
200+
apiData={{ domain: ApiMode.TEST, merchantId: '11gKoO333vEXacMNMUMUSc4c4g68g2Les4' }}
201+
/>
202+
</div>,
203+
)
204+
205+
// Wait for both widgets to render
206+
const containers = await screen.findAllByTestId('widget-container')
207+
expect(containers).toHaveLength(2)
208+
209+
// Verify each widget has its own ID and doesn't conflict
210+
const widgetIds = containers.map((container) => container.id)
211+
expect(widgetIds[0]).toBe('alma-widget-payment-plans-main-container')
212+
expect(widgetIds[1]).toBe('alma-widget-payment-plans-main-container')
213+
214+
// Note: In real implementation, we might want unique IDs for multiple instances
215+
// But current single-instance use case is acceptable
101216

102-
const h6Heading = screen.getByRole('heading', { level: 6 })
103-
expect(h6Heading).toBeInTheDocument()
104-
expect(h6Heading).toHaveTextContent('Informations sur le plan de paiement sélectionné')
217+
// Verify both widgets have proper ARIA structure
218+
const allRegions = screen.getAllByRole('region')
219+
expect(allRegions.length).toBeGreaterThanOrEqual(4) // At least 2 regions per widget
220+
221+
const allHeadings = screen.getAllByRole('heading')
222+
expect(allHeadings.length).toBeGreaterThanOrEqual(4) // At least 2 headings per widget
105223
})
106224

107-
it('should maintain existing listbox functionality with new structure', async () => {
225+
it('should maintain focus management and keyboard navigation', async () => {
108226
render(
109227
<PaymentPlanWidget
110228
purchaseAmount={40000}
@@ -114,13 +232,21 @@ describe('PaymentPlans ARIA Landmarks (RGAA 12.6)', () => {
114232

115233
await screen.findByTestId('widget-container')
116234

117-
// Check listBox still exists and functions within the section
118-
const listBox = screen.getByRole('listbox')
119-
expect(listBox).toBeInTheDocument()
120-
expect(listBox).toHaveAttribute('aria-label', 'Options de paiement disponibles')
235+
// Check main interactive button exists and is focusable
236+
const mainButton = screen.getByRole('button', { name: /Ouvrir les options de paiement Alma/ })
237+
expect(mainButton).toBeInTheDocument()
238+
expect(mainButton).toHaveAttribute('aria-haspopup', 'dialog')
121239

122-
// Check option buttons still exist within the structure
240+
// Check payment option buttons are focusable
123241
const optionButtons = screen.getAllByRole('option')
124-
expect(optionButtons.length).toBeGreaterThan(0)
242+
optionButtons.forEach((button) => {
243+
expect(button).toHaveAttribute('tabindex')
244+
expect(button).toHaveAttribute('type', 'button')
245+
})
246+
247+
// Verify live region exists for screen reader announcements
248+
const alertRegion = screen.getByRole('alert')
249+
expect(alertRegion).toBeInTheDocument()
250+
expect(alertRegion).toHaveAttribute('aria-live', 'assertive')
125251
})
126252
})

0 commit comments

Comments
 (0)