Skip to content

Commit c5e97c3

Browse files
committed
fix(input-otp): inherit aria attributes and hide empty input boxes from screen readers
1 parent acbba7c commit c5e97c3

File tree

3 files changed

+109
-5
lines changed

3 files changed

+109
-5
lines changed

core/src/components/input-otp/input-otp.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
22
import { Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core';
3+
import type { Attributes } from '@utils/helpers';
4+
import { inheritAriaAttributes } from '@utils/helpers';
35
import { printIonWarning } from '@utils/logging';
46
import { isRTL } from '@utils/rtl';
57
import { createColorClasses } from '@utils/theme';
@@ -23,6 +25,7 @@ import type {
2325
scoped: true,
2426
})
2527
export class InputOTP implements ComponentInterface {
28+
private inheritedAttributes: Attributes = {};
2629
private inputRefs: HTMLInputElement[] = [];
2730
private inputId = `ion-input-otp-${inputIds++}`;
2831
private parsedSeparators: number[] = [];
@@ -242,10 +245,15 @@ export class InputOTP implements ComponentInterface {
242245
}
243246

244247
componentWillLoad() {
248+
this.inheritedAttributes = inheritAriaAttributes(this.el);
245249
this.processSeparators();
246250
this.initializeValues();
247251
}
248252

253+
componentDidLoad() {
254+
this.updateTabIndexes();
255+
}
256+
249257
/**
250258
* Initializes the input values array based on the current value prop.
251259
* This splits the value into individual characters and validates them against
@@ -352,6 +360,7 @@ export class InputOTP implements ComponentInterface {
352360
if (newValue.length === length) {
353361
this.ionComplete.emit({ value: newValue });
354362
}
363+
this.updateTabIndexes();
355364
}
356365

357366
/**
@@ -675,13 +684,16 @@ export class InputOTP implements ComponentInterface {
675684
}
676685
}
677686

678-
// Update tabIndex for all inputs
687+
// Update tabIndex and aria-hidden for all inputs
679688
inputRefs.forEach((input, index) => {
680-
// If all boxes are filled, make the last box tabbable
681-
// Otherwise, make the first empty box tabbable
682689
const shouldBeTabbable = firstEmptyIndex === -1 ? index === length - 1 : firstEmptyIndex === index;
683690

684691
input.tabIndex = shouldBeTabbable ? 0 : -1;
692+
693+
// If the input is empty and not the first empty input,
694+
// it should be hidden from screen readers.
695+
const isEmpty = !inputValues[index] || inputValues[index] === '';
696+
input.setAttribute('aria-hidden', isEmpty && !shouldBeTabbable ? 'true' : 'false');
685697
});
686698
}
687699

@@ -716,6 +728,7 @@ export class InputOTP implements ComponentInterface {
716728
disabled,
717729
fill,
718730
hasFocus,
731+
inheritedAttributes,
719732
inputId,
720733
inputRefs,
721734
inputValues,
@@ -741,7 +754,7 @@ export class InputOTP implements ComponentInterface {
741754
'input-otp-readonly': readonly,
742755
})}
743756
>
744-
<div role="group" aria-label="One-time password input" class="input-otp-group">
757+
<div role="group" aria-label="One-time password input" class="input-otp-group" {...inheritedAttributes}>
745758
{Array.from({ length }).map((_, index) => (
746759
<>
747760
<div class="native-wrapper">
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import AxeBuilder from '@axe-core/playwright';
2+
import { expect } from '@playwright/test';
3+
import { configs, test } from '@utils/test/playwright';
4+
5+
/**
6+
* Functionality is the same across modes
7+
*/
8+
configs().forEach(({ title, config }) => {
9+
test.describe(title('input-otp: a11y'), () => {
10+
test('should not have accessibility violations', async ({ page }) => {
11+
await page.setContent(
12+
`
13+
<main>
14+
<ion-input-otp></ion-input-otp>
15+
</main>
16+
`,
17+
config
18+
);
19+
20+
const results = await new AxeBuilder({ page }).analyze();
21+
expect(results.violations).toEqual([]);
22+
});
23+
24+
test('should render with correct aria attributes on initial load', async ({ page }) => {
25+
await page.setContent(`<ion-input-otp></ion-input-otp>`, config);
26+
27+
const inputOtpGroup = page.locator('ion-input-otp .input-otp-group');
28+
await expect(inputOtpGroup).toHaveAttribute('aria-label', 'One-time password input');
29+
30+
const inputBoxes = page.locator('ion-input-otp input');
31+
32+
await expect(inputBoxes.nth(0)).toHaveAttribute('aria-hidden', 'false');
33+
await expect(inputBoxes.nth(1)).toHaveAttribute('aria-hidden', 'true');
34+
await expect(inputBoxes.nth(2)).toHaveAttribute('aria-hidden', 'true');
35+
await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'true');
36+
});
37+
38+
test('should update aria-hidden when value is set', async ({ page }) => {
39+
await page.setContent(`<ion-input-otp value="12"></ion-input-otp>`, config);
40+
41+
const inputBoxes = page.locator('ion-input-otp input');
42+
43+
await expect(inputBoxes.nth(0)).toHaveAttribute('aria-hidden', 'false');
44+
await expect(inputBoxes.nth(1)).toHaveAttribute('aria-hidden', 'false');
45+
await expect(inputBoxes.nth(2)).toHaveAttribute('aria-hidden', 'false');
46+
await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'true');
47+
});
48+
49+
test('should update aria-hidden when typing a value', async ({ page }) => {
50+
await page.setContent(`<ion-input-otp></ion-input-otp>`, config);
51+
52+
const inputBoxes = page.locator('ion-input-otp input');
53+
54+
const firstInput = page.locator('ion-input-otp input').first();
55+
await firstInput.focus();
56+
57+
await page.keyboard.type('123');
58+
59+
await expect(inputBoxes.nth(0)).toHaveAttribute('aria-hidden', 'false');
60+
await expect(inputBoxes.nth(1)).toHaveAttribute('aria-hidden', 'false');
61+
await expect(inputBoxes.nth(2)).toHaveAttribute('aria-hidden', 'false');
62+
await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'false');
63+
});
64+
65+
test('should update aria-hidden when value is cleared using backspace', async ({ page }) => {
66+
await page.setContent(`<ion-input-otp value="12"></ion-input-otp>`, config);
67+
68+
const inputBoxes = page.locator('ion-input-otp input');
69+
70+
await page.keyboard.press('Tab');
71+
await page.keyboard.press('Backspace');
72+
await page.keyboard.press('Backspace');
73+
74+
await expect(inputBoxes.nth(0)).toHaveAttribute('aria-hidden', 'false');
75+
await expect(inputBoxes.nth(1)).toHaveAttribute('aria-hidden', 'true');
76+
await expect(inputBoxes.nth(2)).toHaveAttribute('aria-hidden', 'true');
77+
await expect(inputBoxes.nth(3)).toHaveAttribute('aria-hidden', 'true');
78+
});
79+
80+
test('should update aria-label and aria-labelledby when set on host', async ({ page }) => {
81+
await page.setContent(`<ion-input-otp aria-label="Custom label" aria-labelledby="my-label"></ion-input-otp>`, config);
82+
83+
const inputOtpGroup = page.locator('ion-input-otp .input-otp-group');
84+
await expect(inputOtpGroup).toHaveAttribute('aria-label', 'Custom label');
85+
await expect(inputOtpGroup).toHaveAttribute('aria-labelledby', 'my-label');
86+
});
87+
});
88+
});

core/src/components/input-otp/test/basic/input-otp.e2e.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,10 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => {
262262
});
263263

264264
test('should accept only Cyrillic characters when pattern is set', async ({ page }) => {
265-
await page.setContent(`<ion-input-otp type="text" pattern="[\\p{Script=Cyrillic}]">Description</ion-input-otp>`, config);
265+
await page.setContent(
266+
`<ion-input-otp type="text" pattern="[\\p{Script=Cyrillic}]">Description</ion-input-otp>`,
267+
config
268+
);
266269

267270
const inputOtp = page.locator('ion-input-otp');
268271
const firstInput = page.locator('ion-input-otp input').first();

0 commit comments

Comments
 (0)