Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/puny-places-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Add aria live region to ensure feedback messages are read to screen readers when feedback changes.
6 changes: 4 additions & 2 deletions integration/tests/sign-in-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions integration/tests/sign-in-or-up-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion integration/tests/sign-in-or-up-restricted-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 4 additions & 1 deletion integration/tests/sign-up-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ describe('SignInFactorOne', () => {
const { userEvent } = render(<SignInFactorOne />, { 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 () => {
Expand Down Expand Up @@ -558,7 +559,8 @@ describe('SignInFactorOne', () => {
);
const { userEvent } = render(<SignInFactorOne />, { 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 () => {
Expand Down Expand Up @@ -663,7 +665,8 @@ describe('SignInFactorOne', () => {
);
const { userEvent } = render(<SignInFactorOne />, { 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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe('SignInFactorTwo', () => {
);
const { userEvent } = render(<SignInFactorTwo />, { 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 () => {
Expand Down Expand Up @@ -274,7 +274,7 @@ describe('SignInFactorTwo', () => {
);
const { userEvent } = render(<SignInFactorTwo />, { 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);
});
});

Expand Down Expand Up @@ -367,7 +367,7 @@ describe('SignInFactorTwo', () => {
const { userEvent, getByLabelText, getByText } = render(<SignInFactorTwo />, { 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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
81 changes: 47 additions & 34 deletions packages/clerk-js/src/ui/elements/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -161,38 +162,50 @@ export const FormFeedback = (props: FormFeedbackProps) => {
const InfoComponentB = FormInfoComponent[feedbacks.b?.feedbackType || 'info'];

return (
<Flex
style={{
height: feedback ? maxHeight : 0, // dynamic height
position: 'relative',
}}
center={center}
sx={[getFormTextAnimation(!!feedback), sx]}
>
<InfoComponentA
{...getElementProps(feedbacks.a?.feedbackType)}
ref={calculateHeightA}
sx={[
() => ({
visibility: feedbacks.a?.shouldEnter ? 'visible' : 'hidden',
}),
getFormTextAnimation(!!feedbacks.a?.shouldEnter, { inDelay: true }),
]}
localizationKey={titleize(feedbacks.a?.feedback)}
aria-live={feedbacks.a?.shouldEnter ? 'polite' : 'off'}
/>
<InfoComponentB
{...getElementProps(feedbacks.b?.feedbackType)}
ref={calculateHeightB}
sx={[
() => ({
visibility: feedbacks.b?.shouldEnter ? 'visible' : 'hidden',
}),
getFormTextAnimation(!!feedbacks.b?.shouldEnter, { inDelay: true }),
]}
localizationKey={titleize(feedbacks.b?.feedback)}
aria-live={feedbacks.b?.shouldEnter ? 'polite' : 'off'}
/>
</Flex>
<>
{/* Screen reader only live region that updates when feedback changes */}
<Span
aria-live='polite'
aria-atomic='true'
sx={{
...common.visuallyHidden(),
}}
>
{feedback ? titleize(feedback) : ''}
</Span>
<Flex
style={{
height: feedback ? maxHeight : 0, // dynamic height
position: 'relative',
}}
center={center}
sx={[getFormTextAnimation(!!feedback), sx]}
>
<InfoComponentA
{...getElementProps(feedbacks.a?.feedbackType)}
{...(feedbacks.a?.feedbackType && { 'data-testid': `form-feedback-${feedbacks.a.feedbackType}` })}
ref={calculateHeightA}
sx={[
() => ({
visibility: feedbacks.a?.shouldEnter ? 'visible' : 'hidden',
}),
getFormTextAnimation(!!feedbacks.a?.shouldEnter, { inDelay: true }),
]}
localizationKey={titleize(feedbacks.a?.feedback)}
/>
<InfoComponentB
{...getElementProps(feedbacks.b?.feedbackType)}
{...(feedbacks.b?.feedbackType && { 'data-testid': `form-feedback-${feedbacks.b.feedbackType}` })}
ref={calculateHeightB}
sx={[
() => ({
visibility: feedbacks.b?.shouldEnter ? 'visible' : 'hidden',
}),
getFormTextAnimation(!!feedbacks.b?.shouldEnter, { inDelay: true }),
]}
localizationKey={titleize(feedbacks.b?.feedback)}
/>
</Flex>
</>
);
};
Loading