Skip to content

Commit b4ae95b

Browse files
committed
feat(input-otp): add aria roles
1 parent 693446d commit b4ae95b

File tree

7 files changed

+253
-10
lines changed

7 files changed

+253
-10
lines changed

core/api.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,7 @@ ion-input-otp,prop,fill,"outline" | "solid" | undefined,'outline',false,false
789789
ion-input-otp,prop,helperText,string | undefined,undefined,false,false
790790
ion-input-otp,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined,undefined,false,false
791791
ion-input-otp,prop,length,number,4,false,false
792+
ion-input-otp,prop,mode,"ios" | "md",undefined,false,false
792793
ion-input-otp,prop,pattern,string | undefined,undefined,false,false
793794
ion-input-otp,prop,readonly,boolean,false,false,true
794795
ion-input-otp,prop,separators,number[] | string | undefined,undefined,false,false

core/src/components.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1479,6 +1479,10 @@ export namespace Components {
14791479
* @default 4
14801480
*/
14811481
"length": number;
1482+
/**
1483+
* The mode determines which platform styles to use.
1484+
*/
1485+
"mode"?: "ios" | "md";
14821486
/**
14831487
* A regex pattern string for allowed characters. Defaults based on type. For numbers (`type="number"`): `"[\p{N}]"` For text (`type="text"`): `"[\p{L}\p{N}]"`
14841488
*/
@@ -6769,6 +6773,10 @@ declare namespace LocalJSX {
67696773
* @default 4
67706774
*/
67716775
"length"?: number;
6776+
/**
6777+
* The mode determines which platform styles to use.
6778+
*/
6779+
"mode"?: "ios" | "md";
67726780
/**
67736781
* Emitted when the input group loses focus.
67746782
*/

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

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,15 @@
120120
// Input Description
121121
// ----------------------------------------------------------------
122122

123-
.input-otp-description {
123+
.input-otp-description-hidden {
124+
display: none;
125+
}
126+
127+
// Input Description & Bottom Content
128+
// ----------------------------------------------------------------
129+
130+
.input-otp-description,
131+
.input-otp-bottom {
124132
color: $text-color-step-300;
125133

126134
font-size: dynamic-font(12px);
@@ -130,10 +138,6 @@
130138
text-align: center;
131139
}
132140

133-
.input-otp-description-hidden {
134-
display: none;
135-
}
136-
137141
// Input Separator
138142
// ----------------------------------------------------------------
139143

@@ -271,6 +275,34 @@
271275
--border-color: var(--highlight-color);
272276
}
273277

278+
// Input Hint Text
279+
// ----------------------------------------------------------------
280+
281+
/**
282+
* Error text should only be shown when .ion-invalid is
283+
* present on the input. Otherwise the helper text should
284+
* be shown.
285+
*/
286+
.input-otp-bottom .error-text {
287+
display: none;
288+
289+
color: var(--highlight-color-invalid);
290+
}
291+
292+
.input-otp-bottom .helper-text {
293+
display: block;
294+
295+
color: $text-color-step-300;
296+
}
297+
298+
:host(.ion-touched.ion-invalid) .input-otp-bottom .error-text {
299+
display: block;
300+
}
301+
302+
:host(.ion-touched.ion-invalid) .input-otp-bottom .helper-text {
303+
display: none;
304+
}
305+
274306
// Colors
275307
// ----------------------------------------------------------------
276308

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import type {
1616
InputOtpInputEventDetail,
1717
} from './input-otp-interface';
1818

19+
/**
20+
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
21+
* @slot - The default slot is for the input-otp's description.
22+
*/
1923
@Component({
2024
tag: 'ion-input-otp',
2125
styleUrls: {
@@ -922,15 +926,20 @@ export class InputOTP implements ComponentInterface {
922926
'input-otp-readonly': readonly,
923927
})}
924928
>
925-
<div role="group" aria-label="One-time password input" class="input-otp-group" {...inheritedAttributes}>
929+
<div
930+
role="group"
931+
aria-describedby={this.getHintTextID()}
932+
aria-invalid={this.isInvalid ? 'true' : undefined}
933+
aria-label="One-time password input"
934+
class="input-otp-group"
935+
{...inheritedAttributes}
936+
>
926937
{Array.from({ length }).map((_, index) => (
927938
<>
928939
<div class="native-wrapper">
929940
<input
930941
class="native-input"
931942
id={`${inputId}-${index}`}
932-
aria-describedby={this.getHintTextID()}
933-
aria-invalid={this.isInvalid ? 'true' : undefined}
934943
aria-label={`Input ${index + 1} of ${length}`}
935944
type="text"
936945
autoCapitalize={autocapitalize}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Input OTP - Bottom Content</title>
6+
<meta
7+
name="viewport"
8+
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
<style>
16+
.grid {
17+
display: grid;
18+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
19+
grid-row-gap: 20px;
20+
grid-column-gap: 20px;
21+
}
22+
h2 {
23+
font-size: 12px;
24+
font-weight: normal;
25+
26+
color: #6f7378;
27+
28+
margin-top: 10px;
29+
}
30+
</style>
31+
</head>
32+
33+
<body>
34+
<ion-app>
35+
<ion-header>
36+
<ion-toolbar>
37+
<ion-title>Input OTP - Bottom Content</ion-title>
38+
</ion-toolbar>
39+
</ion-header>
40+
41+
<ion-content id="content" class="ion-padding">
42+
<div class="grid">
43+
<div class="grid-item">
44+
<h2>Description: No Hint</h2>
45+
<ion-input-otp> Description </ion-input-otp>
46+
</div>
47+
48+
<div class="grid-item">
49+
<h2>Description: Helper Text</h2>
50+
<ion-input-otp helper-text="Helper text" error-text="Error text">Description</ion-input-otp>
51+
</div>
52+
53+
<div class="grid-item">
54+
<h2>Description: Error Text</h2>
55+
<ion-input-otp helper-text="Helper text" error-text="Error text" class="ion-invalid ion-touched"
56+
>Description</ion-input-otp
57+
>
58+
</div>
59+
</div>
60+
61+
<div class="grid">
62+
<div class="grid-item">
63+
<h2>No Description: No Hint</h2>
64+
<ion-input-otp></ion-input-otp>
65+
</div>
66+
67+
<div class="grid-item">
68+
<h2>No Description: Helper Text</h2>
69+
<ion-input-otp helper-text="Helper text" error-text="Error text"></ion-input-otp>
70+
</div>
71+
72+
<div class="grid-item">
73+
<h2>No Description: Error Text</h2>
74+
<ion-input-otp
75+
helper-text="Helper text"
76+
error-text="Error text"
77+
class="ion-invalid ion-touched"
78+
></ion-input-otp>
79+
</div>
80+
</div>
81+
82+
<button onclick="toggleValid()" class="expand">Toggle error</button>
83+
84+
<script>
85+
const otps = document.querySelectorAll('ion-input-otp[helper-text]');
86+
87+
function toggleValid() {
88+
otps.forEach((otp) => {
89+
otp.classList.toggle('ion-invalid');
90+
otp.classList.toggle('ion-touched');
91+
});
92+
}
93+
</script>
94+
</ion-content>
95+
</ion-app>
96+
</body>
97+
</html>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { expect } from '@playwright/test';
2+
import { configs, test } from '@utils/test/playwright';
3+
4+
/**
5+
* Functionality is the same across modes & directions
6+
*/
7+
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
8+
test.describe(title('input-otp: bottom content functionality'), () => {
9+
test('should not render bottom content if no hint is enabled', async ({ page }) => {
10+
await page.setContent(`<ion-input-otp> Description </ion-input-otp>`, config);
11+
12+
const bottomEl = page.locator('ion-input-otp .input-otp-bottom');
13+
await expect(bottomEl).toHaveCount(0);
14+
});
15+
test('helper text should be visible initially', async ({ page }) => {
16+
await page.setContent(
17+
`<ion-input-otp helper-text="Helper text" error-text="Error text">Description</ion-input-otp>`,
18+
config
19+
);
20+
21+
const helperText = page.locator('ion-input-otp .helper-text');
22+
const errorText = page.locator('ion-input-otp .error-text');
23+
await expect(helperText).toBeVisible();
24+
await expect(helperText).toHaveText('Helper text');
25+
await expect(errorText).toBeHidden();
26+
});
27+
test('input-otp should have an aria-describedby attribute when helper text is present', async ({ page }) => {
28+
await page.setContent(
29+
`<ion-input-otp helper-text="Helper text" error-text="Error text">Description</ion-input-otp>`,
30+
config
31+
);
32+
33+
const inputOtpGroup = page.locator('ion-input-otp [role="group"]');
34+
const helperText = page.locator('ion-input-otp .helper-text');
35+
const helperTextId = await helperText.getAttribute('id');
36+
const ariaDescribedBy = await inputOtpGroup.getAttribute('aria-describedby');
37+
38+
expect(ariaDescribedBy).toBe(helperTextId);
39+
});
40+
test('error text should be visible when input-otp is invalid', async ({ page }) => {
41+
await page.setContent(
42+
`<ion-input-otp class="ion-invalid ion-touched" helper-text="Helper text" error-text="Error text">Description</ion-input-otp>`,
43+
config
44+
);
45+
46+
const helperText = page.locator('ion-input-otp .helper-text');
47+
const errorText = page.locator('ion-input-otp .error-text');
48+
await expect(helperText).toBeHidden();
49+
await expect(errorText).toBeVisible();
50+
await expect(errorText).toHaveText('Error text');
51+
});
52+
53+
test('input-otp should have an aria-describedby attribute when error text is present', async ({ page }) => {
54+
await page.setContent(
55+
`<ion-input-otp class="ion-invalid ion-touched" helper-text="Helper text" error-text="Error text">Description</ion-input-otp>`,
56+
config
57+
);
58+
59+
const inputOtpGroup = page.locator('ion-input-otp [role="group"]');
60+
const errorText = page.locator('ion-input-otp .error-text');
61+
const errorTextId = await errorText.getAttribute('id');
62+
const ariaDescribedBy = await inputOtpGroup.getAttribute('aria-describedby');
63+
64+
expect(ariaDescribedBy).toBe(errorTextId);
65+
});
66+
test('input-otp should have aria-invalid attribute when input-otp is invalid', async ({ page }) => {
67+
await page.setContent(
68+
`<ion-input-otp class="ion-invalid ion-touched" helper-text="Helper text" error-text="Error text">Description</ion-input-otp>`,
69+
config
70+
);
71+
72+
const inputOtpGroup = page.locator('ion-input-otp [role="group"]');
73+
74+
await expect(inputOtpGroup).toHaveAttribute('aria-invalid');
75+
});
76+
test('input-otp should not have aria-invalid attribute when input-otp is valid', async ({ page }) => {
77+
await page.setContent(
78+
`<ion-input-otp helper-text="Helper text" error-text="Error text">Description</ion-input-otp>`,
79+
config
80+
);
81+
82+
const inputOtpGroup = page.locator('ion-input-otp [role="group"]');
83+
84+
await expect(inputOtpGroup).not.toHaveAttribute('aria-invalid');
85+
});
86+
test('input-otp should not have aria-describedby attribute when no hint or error text is present', async ({
87+
page,
88+
}) => {
89+
await page.setContent(`<ion-input-otp>Description</ion-input-otp>`, config);
90+
91+
const inputOtpGroup = page.locator('ion-input-otp [role="group"]');
92+
93+
await expect(inputOtpGroup).not.toHaveAttribute('aria-describedby');
94+
});
95+
});
96+
});

packages/angular/src/directives/proxies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,15 +1018,15 @@ This event will not emit when programmatically setting the `value` property.
10181018

10191019

10201020
@ProxyCmp({
1021-
inputs: ['autocapitalize', 'color', 'disabled', 'errorText', 'fill', 'helperText', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
1021+
inputs: ['autocapitalize', 'color', 'disabled', 'errorText', 'fill', 'helperText', 'inputmode', 'length', 'mode', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
10221022
methods: ['setFocus']
10231023
})
10241024
@Component({
10251025
selector: 'ion-input-otp',
10261026
changeDetection: ChangeDetectionStrategy.OnPush,
10271027
template: '<ng-content></ng-content>',
10281028
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
1029-
inputs: ['autocapitalize', 'color', 'disabled', 'errorText', 'fill', 'helperText', 'inputmode', 'length', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
1029+
inputs: ['autocapitalize', 'color', 'disabled', 'errorText', 'fill', 'helperText', 'inputmode', 'length', 'mode', 'pattern', 'readonly', 'separators', 'shape', 'size', 'type', 'value'],
10301030
})
10311031
export class IonInputOtp {
10321032
protected el: HTMLIonInputOtpElement;

0 commit comments

Comments
 (0)