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); }); });