Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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.
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ describe('SignInFactorOne', () => {
);
const { userEvent } = render(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456');
await screen.findByText('Incorrect phone code');
await screen.findByText('Incorrect code');
});

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.findByText('Incorrect authenticator code')).toBeDefined();
});

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.findByText('Incorrect phone code')).toBeDefined();
});
});

Expand Down
79 changes: 45 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,48 @@ 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)}
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)}
ref={calculateHeightB}
sx={[
() => ({
visibility: feedbacks.b?.shouldEnter ? 'visible' : 'hidden',
}),
getFormTextAnimation(!!feedbacks.b?.shouldEnter, { inDelay: true }),
]}
localizationKey={titleize(feedbacks.b?.feedback)}
/>
</Flex>
</>
);
};
88 changes: 65 additions & 23 deletions packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';

Check failure on line 1 in packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx

View workflow job for this annotation

GitHub Actions / Static analysis

'screen' is defined but never used

Check failure on line 1 in packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx

View workflow job for this annotation

GitHub Actions / Static analysis

'screen' is defined but never used. Allowed unused vars must match /^_/u
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

Expand Down Expand Up @@ -135,11 +135,13 @@
placeholder: 'some placeholder',
});

const { getByRole, getByLabelText, findByText, container } = render(<Field />, { wrapper });
const { getByRole, getByLabelText, container } = render(<Field />, { wrapper });

await userEvent.click(getByRole('button', { name: /set error/i }));

expect(await findByText(/Some Error/i)).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('#error-firstname')).toBeInTheDocument();
});

const input = getByLabelText(/some label/i);
expect(input).toHaveAttribute('aria-invalid', 'true');
Expand All @@ -160,10 +162,12 @@
infoText: 'some info',
});

const { findByLabelText, findByText } = render(<Field actionLabel={'take action'} />, { wrapper });
const { findByLabelText, container } = render(<Field actionLabel={'take action'} />, { wrapper });

fireEvent.focus(await findByLabelText(/some label/i));
expect(await findByText(/some info/i)).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('#firstname-info-feedback')).toBeInTheDocument();
});
});

it('with success feedback and aria-describedby', async () => {
Expand All @@ -174,11 +178,13 @@
placeholder: 'some placeholder',
});

const { getByRole, getByLabelText, findByText, container } = render(<Field />, { wrapper });
const { getByRole, getByLabelText, container } = render(<Field />, { wrapper });

await userEvent.click(getByRole('button', { name: /set success/i }));

expect(await findByText(/Some Success/i)).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('#firstname-success-feedback')).toBeInTheDocument();
});

const input = getByLabelText(/some label/i);
expect(input).toHaveAttribute('aria-invalid', 'false');
Expand All @@ -198,19 +204,23 @@
placeholder: 'some placeholder',
});

const { getByRole, getByLabelText, findByText, container } = render(<Field />, { wrapper });
const { getByRole, getByLabelText, container } = render(<Field />, { wrapper });

// Start with error
await userEvent.click(getByRole('button', { name: /set error/i }));
expect(await findByText(/Some Error/i)).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('#error-firstname')).toBeInTheDocument();
});

let input = getByLabelText(/some label/i);
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(input).toHaveAttribute('aria-describedby', 'error-firstname');

// Transition to success
await userEvent.click(getByRole('button', { name: /set success/i }));
expect(await findByText(/Some Success/i)).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('#firstname-success-feedback')).toBeInTheDocument();
});

input = getByLabelText(/some label/i);
expect(input).toHaveAttribute('aria-invalid', 'false');
Expand All @@ -222,35 +232,67 @@
expect(successElement).toHaveTextContent(/Some Success/i);
});

it('aria-live attribute is correctly applied', async () => {
it('screen reader only live region announces feedback changes', async () => {
const { wrapper } = await createFixtures();
const { Field } = createField('firstname', 'init value', {
type: 'text',
label: 'some label',
placeholder: 'some placeholder',
});

const { getByRole, findByText, container } = render(<Field />, { wrapper });
const { getByRole, container } = render(<Field />, { wrapper });

// Pre-state: aria-live region exists and is empty
const preRegions = container.querySelectorAll('[aria-live="polite"]');
expect(preRegions.length).toBeGreaterThanOrEqual(1);
const preSrOnly = Array.from(preRegions).find(el => {
const style = window.getComputedStyle(el);
return style.position === 'absolute' && style.width === '1px';
});
expect(preSrOnly).toBeDefined();
expect(preSrOnly?.textContent ?? '').toMatch(/^\s*$/);

// Input is not in error and not described yet
const inputEl = container.querySelector('input#firstname-field');
expect(inputEl).toHaveAttribute('aria-invalid', 'false');
expect(inputEl).not.toHaveAttribute('aria-describedby');

// Set error feedback
await userEvent.click(getByRole('button', { name: /set error/i }));
expect(await findByText(/Some Error/i)).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('#error-firstname')).toBeInTheDocument();
});

// Verify the visible error message has aria-live="polite"
const errorElement = container.querySelector('#error-firstname');
expect(errorElement).toHaveAttribute('aria-live', 'polite');
// Verify there's a screen-reader-only aria-live region with the error content
const ariaLiveRegions = container.querySelectorAll('[aria-live="polite"]');
expect(ariaLiveRegions.length).toBeGreaterThanOrEqual(1);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: If I understood the bug correctly, the announcement did not work because aria-live only announces changes to the DOM element, so maybe this test should also assert that this element is already in the DOM without errors before the set error?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ephem I updated the tests, let me know what you think!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it! They feel tightly scoped now, and like they would catch any regression. 👍


// Find the screen reader only region (it will have the visually hidden styles)
const srOnlyRegion = Array.from(ariaLiveRegions).find(el => {
const style = window.getComputedStyle(el);
return style.position === 'absolute' && style.width === '1px';
});
expect(srOnlyRegion).toBeDefined();
expect(srOnlyRegion).toHaveTextContent(/Some Error/i);

// Transition to success
await userEvent.click(getByRole('button', { name: /set success/i }));
expect(await findByText(/Some Success/i)).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('#firstname-success-feedback')).toBeInTheDocument();
});

// Verify the screen reader only region updated its content
expect(srOnlyRegion).toHaveTextContent(/Some Success/i);

// Verify the visible success message has aria-live="polite"
// Verify the visible error/success elements exist with proper IDs for aria-describedby
const errorElement = container.querySelector('#error-firstname');
const successElement = container.querySelector('#firstname-success-feedback');
expect(successElement).toHaveAttribute('aria-live', 'polite');

// 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);
// One should be visible, the other hidden (for animation)
const errorVisible = errorElement && window.getComputedStyle(errorElement).visibility === 'visible';
const successVisible = successElement && window.getComputedStyle(successElement).visibility === 'visible';

// At least one should be visible (might be both during transition)
expect(errorVisible || successVisible).toBe(true);
});
});
14 changes: 9 additions & 5 deletions packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';

Check failure on line 1 in packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx

View workflow job for this annotation

GitHub Actions / Static analysis

'screen' is defined but never used

Check failure on line 1 in packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx

View workflow job for this annotation

GitHub Actions / Static analysis

'screen' is defined but never used. Allowed unused vars must match /^_/u
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

Expand Down Expand Up @@ -157,7 +157,7 @@
type: 'radio',
});

const { getAllByRole, getByRole, findByText } = render(
const { getAllByRole, getByRole, container } = render(
<Field
radioOptions={[
{ value: 'one', label: 'One' },
Expand All @@ -168,7 +168,9 @@
);

await userEvent.click(getByRole('button', { name: /set error/i }));
expect(await findByText(/Some Error/i)).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('#error-some-radio')).toBeInTheDocument();
});

const radios = getAllByRole('radio');
radios.forEach(radio => {
Expand All @@ -188,9 +190,11 @@
infoText: 'some info',
});

const { findByLabelText, findByText } = render(<Field />, { wrapper });
const { findByLabelText, container } = render(<Field />, { wrapper });

fireEvent.focus(await findByLabelText(/One/i));
expect(await findByText(/some info/i)).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('#some-radio-info-feedback')).toBeInTheDocument();
});
});
});
Loading