Skip to content

Commit 5270151

Browse files
fix(action-sheet): add aria-labelledby (#25837)
1 parent 9cedfcd commit 5270151

File tree

3 files changed

+132
-5
lines changed

3 files changed

+132
-5
lines changed

core/src/components/action-sheet/action-sheet.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,24 +239,25 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
239239
}
240240

241241
render() {
242-
const { htmlAttributes } = this;
242+
const { header, htmlAttributes, overlayIndex } = this;
243243
const mode = getIonMode(this);
244244
const allButtons = this.getButtons();
245245
const cancelButton = allButtons.find((b) => b.role === 'cancel');
246246
const buttons = allButtons.filter((b) => b.role !== 'cancel');
247+
const headerID = `action-sheet-${overlayIndex}-header`;
247248

248249
return (
249250
<Host
250251
role="dialog"
251252
aria-modal="true"
253+
aria-labelledby={header !== undefined ? headerID : null}
252254
tabindex="-1"
253255
{...(htmlAttributes as any)}
254256
style={{
255257
zIndex: `${20000 + this.overlayIndex}`,
256258
}}
257259
class={{
258260
[mode]: true,
259-
260261
...getClassMap(this.cssClass),
261262
'overlay-hidden': true,
262263
'action-sheet-translucent': this.translucent,
@@ -268,17 +269,18 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
268269

269270
<div tabindex="0"></div>
270271

271-
<div class="action-sheet-wrapper ion-overlay-wrapper" role="dialog" ref={(el) => (this.wrapperEl = el)}>
272+
<div class="action-sheet-wrapper ion-overlay-wrapper" ref={(el) => (this.wrapperEl = el)}>
272273
<div class="action-sheet-container">
273274
<div class="action-sheet-group" ref={(el) => (this.groupEl = el)}>
274-
{this.header !== undefined && (
275+
{header !== undefined && (
275276
<div
277+
id={headerID}
276278
class={{
277279
'action-sheet-title': true,
278280
'action-sheet-has-sub-title': this.subHeader !== undefined,
279281
}}
280282
>
281-
{this.header}
283+
{header}
282284
{this.subHeader && <div class="action-sheet-sub-title">{this.subHeader}</div>}
283285
</div>
284286
)}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import AxeBuilder from '@axe-core/playwright';
2+
import { expect } from '@playwright/test';
3+
import type { E2EPage } from '@utils/test/playwright';
4+
import { test } from '@utils/test/playwright';
5+
6+
const testAria = async (page: E2EPage, buttonID: string, expectedAriaLabelledBy: string | null) => {
7+
const didPresent = await page.spyOnEvent('ionActionSheetDidPresent');
8+
const button = page.locator(`#${buttonID}`);
9+
10+
await button.click();
11+
await didPresent.next();
12+
13+
const alert = page.locator('ion-action-sheet');
14+
15+
/**
16+
* expect().toHaveAttribute() can't check for a null value, so grab and check
17+
* the value manually instead.
18+
*/
19+
const ariaLabelledBy = await alert.getAttribute('aria-labelledby');
20+
21+
expect(ariaLabelledBy).toBe(expectedAriaLabelledBy);
22+
};
23+
24+
test.describe('action-sheet: a11y', () => {
25+
test.beforeEach(async ({ page, skip }) => {
26+
skip.rtl();
27+
await page.goto(`/src/components/action-sheet/test/a11y`);
28+
});
29+
30+
test('should not have accessibility violations when header is defined', async ({ page }) => {
31+
const button = page.locator('#bothHeaders');
32+
const didPresent = await page.spyOnEvent('ionActionSheetDidPresent');
33+
34+
await button.click();
35+
await didPresent.next();
36+
37+
/**
38+
* action-sheet overlays the entire screen, so
39+
* Axe will be unable to verify color contrast
40+
* on elements under the backdrop.
41+
*/
42+
const results = await new AxeBuilder({ page }).disableRules('color-contrast').analyze();
43+
expect(results.violations).toEqual([]);
44+
});
45+
46+
test('should have aria-labelledby when header is set', async ({ page }) => {
47+
await testAria(page, 'bothHeaders', 'action-sheet-1-header');
48+
});
49+
50+
test('should not have aria-labelledby when header is not set', async ({ page }) => {
51+
await testAria(page, 'noHeaders', null);
52+
});
53+
54+
test('should allow for manually specifying aria attributes', async ({ page }) => {
55+
await testAria(page, 'customAria', 'Custom title');
56+
});
57+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Action Sheet - A11y</title>
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
7+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
8+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
9+
<script src="../../../../../scripts/testing/scripts.js"></script>
10+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
11+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
12+
</head>
13+
<script type="module">
14+
import { actionSheetController } from '../../../../dist/ionic/index.esm.js';
15+
window.actionSheetController = actionSheetController;
16+
</script>
17+
18+
<body>
19+
<main class="ion-padding">
20+
<h1>Action Sheet - A11y</h1>
21+
22+
<ion-button id="bothHeaders" expand="block" onclick="presentBothHeaders()">Both Headers</ion-button>
23+
<ion-button id="subHeaderOnly" expand="block" onclick="presentSubHeaderOnly()">Subheader Only</ion-button>
24+
<ion-button id="noHeaders" expand="block" onclick="presentNoHeaders()">No Headers</ion-button>
25+
<ion-button id="customAria" expand="block" onclick="presentCustomAria()">Custom Aria</ion-button>
26+
</main>
27+
28+
<script>
29+
async function openActionSheet(opts) {
30+
const actionSheet = await actionSheetController.create(opts);
31+
await actionSheet.present();
32+
}
33+
34+
function presentBothHeaders() {
35+
openActionSheet({
36+
header: 'Header',
37+
subHeader: 'Subtitle',
38+
buttons: ['Confirm'],
39+
});
40+
}
41+
42+
function presentSubHeaderOnly() {
43+
openActionSheet({
44+
subHeader: 'Subtitle',
45+
buttons: ['Confirm'],
46+
});
47+
}
48+
49+
function presentNoHeaders() {
50+
openActionSheet({
51+
buttons: ['Confirm'],
52+
});
53+
}
54+
55+
function presentCustomAria() {
56+
openActionSheet({
57+
header: 'Header',
58+
subHeader: 'Subtitle',
59+
buttons: ['Confirm'],
60+
htmlAttributes: {
61+
'aria-labelledby': 'Custom title',
62+
'aria-describedby': 'Custom description',
63+
},
64+
});
65+
}
66+
</script>
67+
</body>
68+
</html>

0 commit comments

Comments
 (0)