diff --git a/.changeset/puny-places-shine.md b/.changeset/puny-places-shine.md
new file mode 100644
index 00000000000..9f221c753b6
--- /dev/null
+++ b/.changeset/puny-places-shine.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Add aria live region to ensure feedback messages are read to screen readers when feedback changes.
diff --git a/integration/tests/sign-in-flow.test.ts b/integration/tests/sign-in-flow.test.ts
index 20326de4204..0d16c45e327 100644
--- a/integration/tests/sign-in-flow.test.ts
+++ b/integration/tests/sign-in-flow.test.ts
@@ -128,7 +128,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f
await u.po.signIn.continue();
await u.po.signIn.setPassword('wrong-password');
await u.po.signIn.continue();
- await expect(u.page.getByText(/password is incorrect/i)).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toHaveText(/password is incorrect/i);
await u.po.expect.toBeSignedOut();
});
@@ -142,7 +143,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f
await u.po.signIn.setPassword('wrong-password');
await u.po.signIn.continue();
- await expect(u.page.getByText(/password is incorrect/i)).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toHaveText(/password is incorrect/i);
await u.po.signIn.getUseAnotherMethodLink().click();
await u.po.signIn.getAltMethodsEmailCodeButton().click();
diff --git a/integration/tests/sign-in-or-up-flow.test.ts b/integration/tests/sign-in-or-up-flow.test.ts
index c814201be65..bb743f17ac3 100644
--- a/integration/tests/sign-in-or-up-flow.test.ts
+++ b/integration/tests/sign-in-or-up-flow.test.ts
@@ -142,7 +142,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSignInOrUpFlow] })('sign-
await u.po.signIn.continue();
await u.po.signIn.setPassword('wrong-password');
await u.po.signIn.continue();
- await expect(u.page.getByText(/password is incorrect/i)).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toHaveText(/password is incorrect/i);
await u.po.expect.toBeSignedOut();
});
@@ -156,7 +157,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSignInOrUpFlow] })('sign-
await u.po.signIn.setPassword('wrong-password');
await u.po.signIn.continue();
- await expect(u.page.getByText(/password is incorrect/i)).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toHaveText(/password is incorrect/i);
await u.po.signIn.getUseAnotherMethodLink().click();
await u.po.signIn.getAltMethodsEmailCodeButton().click();
diff --git a/integration/tests/sign-in-or-up-restricted-mode.test.ts b/integration/tests/sign-in-or-up-restricted-mode.test.ts
index 0fcab87af2e..66ed92f0f4f 100644
--- a/integration/tests/sign-in-or-up-restricted-mode.test.ts
+++ b/integration/tests/sign-in-or-up-restricted-mode.test.ts
@@ -33,6 +33,7 @@ test.describe('sign-in-or-up restricted mode @nextjs', () => {
await expect(u.page.getByText(/continue to/i)).toBeHidden();
await u.po.signIn.getIdentifierInput().fill(fakeUser.email);
await u.po.signIn.continue();
- await expect(u.page.getByText(/Couldn't find your account\./i)).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error')).toHaveText(/Couldn't find your account\./i);
});
});
diff --git a/integration/tests/sign-up-flow.test.ts b/integration/tests/sign-up-flow.test.ts
index af9df350f4c..d9ba89d6e51 100644
--- a/integration/tests/sign-up-flow.test.ts
+++ b/integration/tests/sign-up-flow.test.ts
@@ -54,7 +54,10 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign up f
});
// Check if password error is visible
- await expect(u.page.getByText(/your password must contain \d+ or more characters/i).first()).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error').first()).toBeVisible();
+ await expect(u.page.getByTestId('form-feedback-error').first()).toHaveText(
+ /your password must contain \d+ or more characters/i,
+ );
// Check if user is signed out
await u.po.expect.toBeSignedOut();
diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/ResetPassword.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/ResetPassword.test.tsx
index f5778d8c918..72aa83a13b5 100644
--- a/packages/clerk-js/src/ui/components/SignIn/__tests__/ResetPassword.test.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/ResetPassword.test.tsx
@@ -37,7 +37,8 @@ describe('ResetPassword', () => {
const passwordField = screen.getByLabelText(/New password/i);
fireEvent.focus(passwordField);
- await screen.findByText(/Your password must contain 8 or more characters/i);
+ const infoElement = await screen.findByTestId('form-feedback-info');
+ expect(infoElement).toHaveTextContent(/Your password must contain 8 or more characters/i);
});
it('renders a hidden identifier field', async () => {
@@ -115,10 +116,12 @@ describe('ResetPassword', () => {
await userEvent.type(screen.getByLabelText(/new password/i), 'testewrewr');
const confirmField = screen.getByLabelText(/confirm password/i);
await userEvent.type(confirmField, 'testrwerrwqrwe');
- await screen.findByText(`Passwords don't match.`);
+ const errorElement = await screen.findByTestId('form-feedback-error');
+ expect(errorElement).toHaveTextContent(/Passwords don't match/i);
await userEvent.clear(confirmField);
- await screen.findByText(`Passwords don't match.`);
+ const errorElementAfterClear = await screen.findByTestId('form-feedback-error');
+ expect(errorElementAfterClear).toHaveTextContent(/Passwords don't match/i);
});
it('navigates to the root page upon pressing the back link', async () => {
diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx
index d784f16ad3a..d610e2d9150 100644
--- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx
@@ -186,7 +186,8 @@ describe('SignInFactorOne', () => {
const { userEvent } = render(, { wrapper });
await userEvent.type(screen.getByLabelText('Password'), '123456');
await userEvent.click(screen.getByText('Continue'));
- await screen.findByText('Incorrect Password');
+ const errorElement = await screen.findByTestId('form-feedback-error');
+ expect(errorElement).toHaveTextContent(/Incorrect Password/i);
});
it('redirects back to sign-in if the user is locked', async () => {
@@ -558,7 +559,8 @@ describe('SignInFactorOne', () => {
);
const { userEvent } = render(, { wrapper });
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
- await screen.findByText('Incorrect code');
+ const errorElement = await screen.findByTestId('form-feedback-error');
+ expect(errorElement).toHaveTextContent(/Incorrect code/i);
});
it('redirects back to sign-in if the user is locked', async () => {
@@ -663,7 +665,8 @@ describe('SignInFactorOne', () => {
);
const { userEvent } = render(, { wrapper });
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
- await screen.findByText('Incorrect phone code');
+ const errorElement = await screen.findByTestId('form-feedback-error');
+ expect(errorElement).toHaveTextContent(/Incorrect phone code/i);
});
it('redirects back to sign-in if the user is locked', async () => {
diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorTwo.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorTwo.test.tsx
index 019e6324047..3d944954df9 100644
--- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorTwo.test.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorTwo.test.tsx
@@ -185,7 +185,7 @@ describe('SignInFactorTwo', () => {
);
const { userEvent } = render(, { wrapper });
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
- expect(await screen.findByText('Incorrect phone code')).toBeDefined();
+ expect(await screen.findByTestId('form-feedback-error')).toHaveTextContent(/Incorrect phone code/i);
});
it('redirects back to sign-in if the user is locked', async () => {
@@ -274,7 +274,7 @@ describe('SignInFactorTwo', () => {
);
const { userEvent } = render(, { wrapper });
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
- expect(await screen.findByText('Incorrect authenticator code')).toBeDefined();
+ expect(await screen.findByTestId('form-feedback-error')).toHaveTextContent(/Incorrect authenticator code/i);
});
});
@@ -367,7 +367,7 @@ describe('SignInFactorTwo', () => {
const { userEvent, getByLabelText, getByText } = render(, { wrapper });
await userEvent.type(getByLabelText('Backup code'), '123456');
await userEvent.click(getByText('Continue'));
- expect(await screen.findByText('Incorrect backup code')).toBeDefined();
+ expect(await screen.findByTestId('form-feedback-error')).toHaveTextContent(/Incorrect backup code/i);
});
it('redirects back to sign-in if the user is locked', async () => {
diff --git a/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpContinue.test.tsx b/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpContinue.test.tsx
index ca78577f8c3..96495d93cd3 100644
--- a/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpContinue.test.tsx
+++ b/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpContinue.test.tsx
@@ -168,9 +168,8 @@ describe('SignUpContinue', () => {
await userEvent.click(button);
await waitFor(() => expect(fixtures.signUp.update).toHaveBeenCalled());
- await waitFor(() =>
- expect(screen.queryByText(/^Your username must be between 4 and 40 characters long./i)).toBeInTheDocument(),
- );
+ const errorElement = await screen.findByTestId('form-feedback-error');
+ expect(errorElement).toHaveTextContent(/Your username must be between 4 and 40 characters long/i);
});
it('renders error for existing username', async () => {
@@ -203,9 +202,8 @@ describe('SignUpContinue', () => {
await userEvent.click(button);
await waitFor(() => expect(fixtures.signUp.update).toHaveBeenCalled());
- await waitFor(() =>
- expect(screen.queryByText(/^This username is taken. Please try another./i)).toBeInTheDocument(),
- );
+ const errorElement = await screen.findByTestId('form-feedback-error');
+ expect(errorElement).toHaveTextContent(/This username is taken. Please try another/i);
});
describe('Sign in Link', () => {
diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasswordSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasswordSection.test.tsx
index f23eaff6692..bfe8e96d82c 100644
--- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasswordSection.test.tsx
+++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/PasswordSection.test.tsx
@@ -538,7 +538,7 @@ describe('PasswordSection', () => {
await userEvent.type(confirmField, 'test');
fireEvent.blur(confirmField);
await waitFor(() => {
- screen.getByText(/or more/i);
+ expect(screen.getByTestId('form-feedback-error')).toHaveTextContent(/or more/i);
});
});
diff --git a/packages/clerk-js/src/ui/elements/FormControl.tsx b/packages/clerk-js/src/ui/elements/FormControl.tsx
index d94887dfddd..045c1aa77e6 100644
--- a/packages/clerk-js/src/ui/elements/FormControl.tsx
+++ b/packages/clerk-js/src/ui/elements/FormControl.tsx
@@ -9,12 +9,13 @@ import {
FormInfoText,
FormSuccessText,
FormWarningText,
+ Span,
useAppearance,
} from '../customizables';
import type { ElementDescriptor } from '../customizables/elementDescriptors';
import { usePrefersReducedMotion } from '../hooks';
import type { ThemableCssProp } from '../styledSystem';
-import { animations } from '../styledSystem';
+import { animations, common } from '../styledSystem';
import type { FeedbackType, useFormControlFeedback } from '../utils/useFormControl';
function useFormTextAnimation() {
@@ -161,38 +162,50 @@ export const FormFeedback = (props: FormFeedbackProps) => {
const InfoComponentB = FormInfoComponent[feedbacks.b?.feedbackType || 'info'];
return (
-
- ({
- visibility: feedbacks.a?.shouldEnter ? 'visible' : 'hidden',
- }),
- getFormTextAnimation(!!feedbacks.a?.shouldEnter, { inDelay: true }),
- ]}
- localizationKey={titleize(feedbacks.a?.feedback)}
- aria-live={feedbacks.a?.shouldEnter ? 'polite' : 'off'}
- />
- ({
- visibility: feedbacks.b?.shouldEnter ? 'visible' : 'hidden',
- }),
- getFormTextAnimation(!!feedbacks.b?.shouldEnter, { inDelay: true }),
- ]}
- localizationKey={titleize(feedbacks.b?.feedback)}
- aria-live={feedbacks.b?.shouldEnter ? 'polite' : 'off'}
- />
-
+ <>
+ {/* Screen reader only live region that updates when feedback changes */}
+
+ {feedback ? titleize(feedback) : ''}
+
+
+ ({
+ visibility: feedbacks.a?.shouldEnter ? 'visible' : 'hidden',
+ }),
+ getFormTextAnimation(!!feedbacks.a?.shouldEnter, { inDelay: true }),
+ ]}
+ localizationKey={titleize(feedbacks.a?.feedback)}
+ />
+ ({
+ visibility: feedbacks.b?.shouldEnter ? 'visible' : 'hidden',
+ }),
+ getFormTextAnimation(!!feedbacks.b?.shouldEnter, { inDelay: true }),
+ ]}
+ localizationKey={titleize(feedbacks.b?.feedback)}
+ />
+
+ >
);
};
diff --git a/packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx b/packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx
index 6adaec9b8f2..f5bb873a981 100644
--- a/packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx
+++ b/packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render } from '@testing-library/react';
+import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
@@ -135,11 +135,12 @@ describe('PlainInput', () => {
placeholder: 'some placeholder',
});
- const { getByRole, getByLabelText, findByText, container } = render(, { wrapper });
+ const { getByRole, getByLabelText, findByTestId, container } = render(, { wrapper });
await userEvent.click(getByRole('button', { name: /set error/i }));
- expect(await findByText(/Some Error/i)).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-error')).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-error')).toHaveTextContent(/Some Error/i);
const input = getByLabelText(/some label/i);
expect(input).toHaveAttribute('aria-invalid', 'true');
@@ -160,10 +161,11 @@ describe('PlainInput', () => {
infoText: 'some info',
});
- const { findByLabelText, findByText } = render(, { wrapper });
+ const { findByLabelText, findByTestId } = render(, { wrapper });
fireEvent.focus(await findByLabelText(/some label/i));
- expect(await findByText(/some info/i)).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-info')).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-info')).toHaveTextContent(/some info/i);
});
it('with success feedback and aria-describedby', async () => {
@@ -174,11 +176,12 @@ describe('PlainInput', () => {
placeholder: 'some placeholder',
});
- const { getByRole, getByLabelText, findByText, container } = render(, { wrapper });
+ const { getByRole, getByLabelText, findByTestId, container } = render(, { wrapper });
await userEvent.click(getByRole('button', { name: /set success/i }));
- expect(await findByText(/Some Success/i)).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-success')).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-success')).toHaveTextContent(/Some Success/i);
const input = getByLabelText(/some label/i);
expect(input).toHaveAttribute('aria-invalid', 'false');
@@ -198,11 +201,12 @@ describe('PlainInput', () => {
placeholder: 'some placeholder',
});
- const { getByRole, getByLabelText, findByText, container } = render(, { wrapper });
+ const { getByRole, getByLabelText, findByTestId, container } = render(, { wrapper });
// Start with error
await userEvent.click(getByRole('button', { name: /set error/i }));
- expect(await findByText(/Some Error/i)).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-error')).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-error')).toHaveTextContent(/Some Error/i);
let input = getByLabelText(/some label/i);
expect(input).toHaveAttribute('aria-invalid', 'true');
@@ -210,7 +214,8 @@ describe('PlainInput', () => {
// Transition to success
await userEvent.click(getByRole('button', { name: /set success/i }));
- expect(await findByText(/Some Success/i)).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-success')).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-success')).toHaveTextContent(/Some Success/i);
input = getByLabelText(/some label/i);
expect(input).toHaveAttribute('aria-invalid', 'false');
@@ -222,7 +227,7 @@ describe('PlainInput', () => {
expect(successElement).toHaveTextContent(/Some Success/i);
});
- it('aria-live attribute is correctly applied', async () => {
+ it('renders the aria-live region for screen reader support', async () => {
const { wrapper } = await createFixtures();
const { Field } = createField('firstname', 'init value', {
type: 'text',
@@ -230,27 +235,34 @@ describe('PlainInput', () => {
placeholder: 'some placeholder',
});
- const { getByRole, findByText, container } = render(, { wrapper });
+ const { getByRole, container } = render(, { wrapper });
- // Set error feedback
- await userEvent.click(getByRole('button', { name: /set error/i }));
- expect(await findByText(/Some Error/i)).toBeInTheDocument();
+ const ariaLiveRegion = container.querySelector('[aria-live="polite"]');
+ expect(ariaLiveRegion).toBeInTheDocument();
- // Verify the visible error message has aria-live="polite"
- const errorElement = container.querySelector('#error-firstname');
- expect(errorElement).toHaveAttribute('aria-live', 'polite');
+ expect(ariaLiveRegion).toHaveAttribute('aria-live', 'polite');
+ expect(ariaLiveRegion).toHaveAttribute('aria-atomic', 'true');
- // Transition to success
- await userEvent.click(getByRole('button', { name: /set success/i }));
- expect(await findByText(/Some Success/i)).toBeInTheDocument();
+ // It is visually hidden
+ expect(ariaLiveRegion).toHaveStyle({
+ position: 'absolute',
+ width: '1px',
+ height: '1px',
+ overflow: 'hidden',
+ });
- // Verify the visible success message has aria-live="polite"
- const successElement = container.querySelector('#firstname-success-feedback');
- expect(successElement).toHaveAttribute('aria-live', 'polite');
+ expect(ariaLiveRegion).toHaveTextContent('');
- // The previous error message should now have aria-live="off" (though it might still exist in DOM but hidden)
- // Verify exactly one element has aria-live="polite" at a time
- const allAriaLivePolite = container.querySelectorAll('[aria-live="polite"]');
- expect(allAriaLivePolite.length).toBe(1);
+ await userEvent.click(getByRole('button', { name: /set error/i }));
+ await waitFor(() => {
+ const ariaLiveRegion = container.querySelector('[aria-live="polite"]');
+ expect(ariaLiveRegion?.textContent).toMatch(/Some Error/i);
+ });
+
+ await userEvent.click(getByRole('button', { name: /set success/i }));
+ await waitFor(() => {
+ const ariaLiveRegion = container.querySelector('[aria-live="polite"]');
+ expect(ariaLiveRegion?.textContent).toMatch(/Some Success/i);
+ });
});
});
diff --git a/packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx b/packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx
index ad56cae4364..09ff09b7d26 100644
--- a/packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx
+++ b/packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx
@@ -157,7 +157,7 @@ describe('RadioGroup', () => {
type: 'radio',
});
- const { getAllByRole, getByRole, findByText } = render(
+ const { getAllByRole, getByRole, findByTestId } = render(
{
);
await userEvent.click(getByRole('button', { name: /set error/i }));
- expect(await findByText(/Some Error/i)).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-error')).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-error')).toHaveTextContent(/Some Error/i);
const radios = getAllByRole('radio');
radios.forEach(radio => {
@@ -188,9 +189,10 @@ describe('RadioGroup', () => {
infoText: 'some info',
});
- const { findByLabelText, findByText } = render(, { wrapper });
+ const { findByLabelText, findByTestId } = render(, { wrapper });
fireEvent.focus(await findByLabelText(/One/i));
- expect(await findByText(/some info/i)).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-info')).toBeInTheDocument();
+ expect(await findByTestId('form-feedback-info')).toHaveTextContent(/some info/i);
});
});