Skip to content

Commit 31b0934

Browse files
authored
fix(gen3): re-announce form validation errors on repeated submissions (#3973)
OKTA-1109288 fix(gen3): re-announce form validation errors on r lint
1 parent 7cbbc8a commit 31b0934

2 files changed

Lines changed: 113 additions & 2 deletions

File tree

src/v3/src/components/Form/Form.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import { Box } from '@mui/material';
1414
import { FunctionComponent, h } from 'preact';
15-
import { useCallback, useEffect } from 'preact/hooks';
15+
import { useCallback, useEffect, useRef } from 'preact/hooks';
1616

1717
import { useWidgetContext } from '../../contexts';
1818
import { useOnSubmit, useOnSubmitValidation } from '../../hooks';
@@ -37,6 +37,9 @@ const Form: FunctionComponent<{
3737
} = useWidgetContext();
3838
const onSubmitHandler = useOnSubmit();
3939
const onValidationHandler = useOnSubmitValidation();
40+
// Counter to force InfoSection (and its role="alert" Callout) to remount on each
41+
// submission so screen readers re-announce error messages even when the text is unchanged.
42+
const submitSeqRef = useRef(0);
4043

4144
useEffect(() => {
4245
setWidgetRendered(true);
@@ -51,6 +54,7 @@ const Form: FunctionComponent<{
5154
return;
5255
}
5356

57+
submitSeqRef.current += 1;
5458
setMessage(undefined);
5559

5660
const {
@@ -114,7 +118,10 @@ const Form: FunctionComponent<{
114118
}}
115119
aria-live="polite"
116120
>
117-
<InfoSection message={message} />
121+
<InfoSection
122+
key={submitSeqRef.current}
123+
message={message}
124+
/>
118125
<Layout uischema={uischema} />
119126
</Box>
120127
);
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* Copyright (c) 2026-present, Okta, Inc. and/or its affiliates. All rights reserved.
3+
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
4+
*
5+
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
8+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
*
10+
* See the License for the specific language governing permissions and limitations under the License.
11+
*/
12+
13+
import { waitFor } from '@testing-library/preact';
14+
import { setup } from './util';
15+
16+
import mockResponse from '../../src/mocks/response/idp/idx/introspect/default.json';
17+
18+
describe('Form error re-announcement for screen readers', () => {
19+
it('should re-create the error alert DOM element on repeated empty form submissions', async () => {
20+
const {
21+
user,
22+
findByRole,
23+
findByText,
24+
findByLabelText,
25+
} = await setup({ mockResponse });
26+
27+
await findByLabelText(/Username/) as HTMLInputElement;
28+
const submitButton = await findByText('Sign in', { selector: 'button' });
29+
30+
// First submission with empty fields — error alert appears
31+
await user.click(submitButton);
32+
const firstAlert = await findByRole('alert');
33+
expect(firstAlert).toBeInTheDocument();
34+
expect(firstAlert.textContent).toContain('We found some errors.');
35+
36+
// Second submission with same empty fields — alert must be a NEW DOM node
37+
// so that screen readers (VoiceOver, NVDA, JAWS) re-announce it
38+
await user.click(submitButton);
39+
const secondAlert = await findByRole('alert');
40+
expect(secondAlert).toBeInTheDocument();
41+
expect(secondAlert.textContent).toContain('We found some errors.');
42+
43+
// The key assertion: the alert element must be a brand-new DOM node,
44+
// not the same one reused from the first submission.
45+
// A new DOM node with role="alert" forces screen readers to re-announce.
46+
expect(secondAlert).not.toBe(firstAlert);
47+
});
48+
49+
it('should re-create the error alert on a third consecutive submission as well', async () => {
50+
const {
51+
user,
52+
findByRole,
53+
findByText,
54+
findByLabelText,
55+
} = await setup({ mockResponse });
56+
57+
await findByLabelText(/Username/) as HTMLInputElement;
58+
const submitButton = await findByText('Sign in', { selector: 'button' });
59+
60+
// Submit three times consecutively with empty fields
61+
await user.click(submitButton);
62+
const firstAlert = await findByRole('alert');
63+
64+
await user.click(submitButton);
65+
const secondAlert = await findByRole('alert');
66+
67+
await user.click(submitButton);
68+
const thirdAlert = await findByRole('alert');
69+
70+
// All three alerts should be distinct DOM nodes
71+
expect(secondAlert).not.toBe(firstAlert);
72+
expect(thirdAlert).not.toBe(secondAlert);
73+
expect(thirdAlert).not.toBe(firstAlert);
74+
});
75+
76+
it('should still clear the error alert when validation passes after a failed submission', async () => {
77+
const {
78+
authClient,
79+
user,
80+
findByRole,
81+
findByText,
82+
findByLabelText,
83+
queryByRole,
84+
} = await setup({ mockResponse });
85+
86+
const usernameEl = await findByLabelText(/Username/) as HTMLInputElement;
87+
const passwordEl = await findByLabelText('Password') as HTMLInputElement;
88+
const submitButton = await findByText('Sign in', { selector: 'button' });
89+
90+
// Submit empty form — error alert appears
91+
await user.click(submitButton);
92+
await findByRole('alert');
93+
94+
// Fill in both fields and submit — error should clear
95+
await user.type(usernameEl, 'testuser@okta.com');
96+
await user.type(passwordEl, 'fake-password');
97+
await user.click(submitButton);
98+
99+
await waitFor(() => {
100+
expect(queryByRole('alert')).not.toBeInTheDocument();
101+
expect(authClient.options.httpRequestClient).toHaveBeenCalled();
102+
});
103+
});
104+
});

0 commit comments

Comments
 (0)