Skip to content

Commit 325fc43

Browse files
fix(ui): Ensure hidden passworld field is hidden from a11y tree (#8899)
1 parent e51e22a commit 325fc43

3 files changed

Lines changed: 65 additions & 2 deletions

File tree

.changeset/cold-dodos-lay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': patch
3+
---
4+
5+
Remove hidden password input from accessibility tree when hidden

packages/ui/src/components/SignIn/SignInStart.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -734,14 +734,26 @@ const InstantPasswordRow = ({
734734
return (
735735
<Form.ControlRow
736736
elementId={field.id}
737-
sx={show ? undefined : { position: 'absolute', opacity: 0, height: 0, pointerEvents: 'none', marginTop: '-1rem' }}
737+
aria-hidden={show ? undefined : true}
738+
sx={
739+
show
740+
? undefined
741+
: {
742+
position: 'absolute',
743+
opacity: 0,
744+
height: 0,
745+
overflow: 'hidden',
746+
pointerEvents: 'none',
747+
marginTop: '-1rem',
748+
}
749+
}
738750
>
739751
<Form.PasswordInput
740752
{...field.props}
741753
actionLabel={show ? localizationKeys('formFieldAction__forgotPassword') : undefined}
742754
onActionClicked={show ? onForgotPasswordClick : undefined}
743-
ref={ref}
744755
tabIndex={show ? undefined : -1}
756+
ref={ref}
745757
/>
746758
</Form.ControlRow>
747759
);

packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,52 @@ describe('SignInStart', () => {
573573
});
574574
});
575575

576+
describe('Instant password field a11y', () => {
577+
it('hides the empty instant password field from the a11y tree via aria-hidden', async () => {
578+
const { wrapper } = await createFixtures(f => {
579+
f.withEmailAddress();
580+
f.withPassword({ required: true });
581+
});
582+
const { container } = render(<SignInStart />, { wrapper });
583+
584+
const passwordField = container.querySelector('#password-field') as HTMLElement;
585+
expect(passwordField).not.toBeNull();
586+
587+
const row = passwordField.closest('[class*="formFieldRow"]') as HTMLElement;
588+
expect(row).toHaveAttribute('aria-hidden', 'true');
589+
});
590+
591+
it('reveals the instant password field when the browser autofills it', async () => {
592+
// Simulate the browser's :autofill animation that the component polls for
593+
mockGetComputedStyle.mockReturnValue({
594+
animationName: 'onAutoFillStart',
595+
pointerEvents: 'auto',
596+
getPropertyValue: vi.fn().mockReturnValue(''),
597+
});
598+
599+
const { wrapper } = await createFixtures(f => {
600+
f.withEmailAddress();
601+
f.withPassword({ required: true });
602+
});
603+
const { container } = render(<SignInStart />, { wrapper });
604+
605+
const passwordField = container.querySelector('#password-field') as HTMLElement;
606+
const row = passwordField.closest('[class*="formFieldRow"]') as HTMLElement;
607+
608+
// initially hidden from a11y tree until the autofill poll fires
609+
expect(row).toHaveAttribute('aria-hidden', 'true');
610+
611+
await waitFor(
612+
() => {
613+
expect(row).not.toHaveAttribute('aria-hidden');
614+
},
615+
{ timeout: 2000 },
616+
);
617+
// Forgot password action only renders once the field is shown
618+
screen.getByText(/Forgot password/i);
619+
});
620+
});
621+
576622
describe('Session already exists error handling', () => {
577623
it('redirects user when session_exists error is returned during sign-in', async () => {
578624
const { wrapper, fixtures } = await createFixtures(f => {

0 commit comments

Comments
 (0)