From dca2a10b23be4af8ac7340acd554240c07864010 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 20:56:56 -0600 Subject: [PATCH 1/2] fix(ui): use flow autofocus for email verification --- .../user-profile-add-email.dialog.test.tsx | 3 +- ...ser-profile-add-email.integration.test.tsx | 4 +- .../user-profile-add-email.dialog.tsx | 310 ++++++++++-------- 3 files changed, 187 insertions(+), 130 deletions(-) diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx index 5f7892f3338..86eaa964d24 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx @@ -63,7 +63,8 @@ describe('UserProfileAddEmailDialog', () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(); - await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); + await user.click(screen.getByRole('textbox', { name: 'Verification code' })); if (method === 'typing') { await user.keyboard('12345'); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx index ceb1810f670..6b82e9d908f 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx @@ -30,7 +30,9 @@ describe('profile add email', () => { expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); await user.click(screen.getByRole('button', { name: 'Send code' })); - await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(codeInput).toHaveFocus()); + await user.keyboard('123456'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx index f7aaeab07a2..9d8008cd997 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import type { FormEvent } from 'react'; +import type { FormEvent, Ref } from 'react'; import { useId, useRef } from 'react'; import { Button, SubmitButton } from '../../../components/button'; @@ -7,7 +7,7 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import { Flow } from '../../../components/flow'; +import { Flow, useFlowAutoFocus } from '../../../components/flow'; import { Input } from '../../../components/input'; import { Otp } from '../../../components/otp'; import { styles } from '../user-profile-profile-panel.styles'; @@ -32,16 +32,7 @@ export interface UserProfileAddEmailDialogProps { } export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) { - const emailFormId = useId(); - const verifyFormId = useId(); const emailRef = useRef(null); - const verifyRef = useRef(null); - const [beforeSeconds, afterSeconds] = m.verify.resendCountdown.split('{seconds}'); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - props.onSubmit(); - }; return ( : null} - emailRef.current ?? verifyRef.current?.querySelector('input:not([type="hidden"])') ?? true - } + initialFocus={props.step === 'email' ? emailRef : undefined} > ( <> - - {m.email.title} - {m.email.description} - - - } - > - - {m.email.label} - current.onEmailAddressChange(event.target.value)} - /> - {current.errorMessage ? {current.errorMessage} : null} - - - - - {m.email.submit} - - + - - - {m.verify.title} - - {fill(m.verify.description, { emailAddress: current.emailAddress })} - - - - } - > - - {m.verify.label} - - {current.errorMessage ? {current.errorMessage} : null} - - - - - - } - > - {m.verify.cancel} - - - {m.verify.submit} - - + + )} @@ -191,3 +86,162 @@ export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) ); } + +interface EnterEmailStepProps { + inputRef: Ref; + emailAddress: string; + onEmailAddressChange: (value: string) => void; + onSubmit: () => void; + isPending?: boolean; + errorMessage?: string; +} + +function EnterEmailStep(props: EnterEmailStepProps) { + const emailFormId = useId(); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + props.onSubmit(); + }; + + return ( + <> + + {m.email.title} + {m.email.description} + + + } + > + + {m.email.label} + props.onEmailAddressChange(event.target.value)} + /> + {props.errorMessage ? {props.errorMessage} : null} + + + + + {m.email.submit} + + + + ); +} + +interface VerifyEmailStepProps { + emailAddress: string; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +function VerifyEmailStep(props: VerifyEmailStepProps) { + const verifyFormId = useId(); + const [beforeSeconds, afterSeconds] = m.verify.resendCountdown.split('{seconds}'); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + props.onSubmit(); + }; + + return ( + <> + + {m.verify.title} + {fill(m.verify.description, { emailAddress: props.emailAddress })} + + + } + > + + {m.verify.label} + ()} + name='code' + value={props.code} + onValueChange={props.onCodeChange} + onComplete={props.onSubmit} + /> + {props.errorMessage ? {props.errorMessage} : null} + + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + ); +} From fd82262e2de1efdf4d6885c8e976716b5b8fb555 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 15 Sep 2026 20:57:17 -0600 Subject: [PATCH 2/2] fix(ui): use flow autofocus for phone verification --- .changeset/honest-chairs-cheat.md | 2 + .../user-profile-add-phone.dialog.test.tsx | 3 +- ...ser-profile-add-phone.integration.test.tsx | 4 +- .../user-profile-add-phone.dialog.tsx | 316 +++++++++++------- 4 files changed, 193 insertions(+), 132 deletions(-) create mode 100644 .changeset/honest-chairs-cheat.md diff --git a/.changeset/honest-chairs-cheat.md b/.changeset/honest-chairs-cheat.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/honest-chairs-cheat.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx index 8ad5297eb5e..579e56d6e3f 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx @@ -55,7 +55,8 @@ describe('UserProfileAddPhoneDialog', () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(); - await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); + await user.click(screen.getByRole('textbox', { name: 'Verification code' })); if (method === 'typing') { await user.keyboard('12345'); diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx index 876f2bc2382..38918699fe8 100644 --- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx @@ -30,7 +30,9 @@ describe('profile add phone', () => { expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); await user.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100'); await user.click(screen.getByRole('button', { name: 'Send code' })); - await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(codeInput).toHaveFocus()); + await user.keyboard('123456'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx index 49079616771..00842155302 100644 --- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import type { FormEvent } from 'react'; +import type { FormEvent, Ref } from 'react'; import { useId, useRef } from 'react'; import { stringToFormattedPhoneString } from '../../../../utils/phoneUtils'; @@ -8,7 +8,7 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; -import { Flow } from '../../../components/flow'; +import { Flow, useFlowAutoFocus } from '../../../components/flow'; import { Otp } from '../../../components/otp'; import { PhoneInput } from '../../../components/phone-input'; import { styles } from '../user-profile-profile-panel.styles'; @@ -33,16 +33,7 @@ export interface UserProfileAddPhoneDialogProps { } export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) { - const phoneFormId = useId(); - const verifyFormId = useId(); const phoneRef = useRef(null); - const verifyRef = useRef(null); - const [beforeSeconds, afterSeconds] = m.verify.resendCountdown.split('{seconds}'); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - props.onSubmit(); - }; return ( : null} - phoneRef.current ?? verifyRef.current?.querySelector('input:not([type="hidden"])') ?? true - } + initialFocus={props.step === 'phone' ? phoneRef : undefined} > ( <> - - {m.phone.title} - {m.phone.description} - - - } - > - - {m.phone.label} - - - {current.errorMessage} - - - - - - {m.phone.submit} - - + - - - {m.verify.title} - - {fill(m.verify.description, { phoneNumber: stringToFormattedPhoneString(current.phoneNumber) })} - - - - } - > - - {m.verify.label} - - - {current.errorMessage} - - - - - - - } - > - {m.verify.cancel} - - - {m.verify.submit} - - + + )} @@ -194,3 +87,166 @@ export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) ); } + +interface EnterPhoneStepProps { + inputRef: Ref; + phoneNumber: string; + onPhoneNumberChange: (value: string) => void; + onSubmit: () => void; + isPending?: boolean; + errorMessage?: string; +} + +function EnterPhoneStep(props: EnterPhoneStepProps) { + const phoneFormId = useId(); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + props.onSubmit(); + }; + + return ( + <> + + {m.phone.title} + {m.phone.description} + + + } + > + + {m.phone.label} + + + {props.errorMessage} + + + + + + {m.phone.submit} + + + + ); +} + +interface VerifyPhoneStepProps { + phoneNumber: string; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +function VerifyPhoneStep(props: VerifyPhoneStepProps) { + const verifyFormId = useId(); + const [beforeSeconds, afterSeconds] = m.verify.resendCountdown.split('{seconds}'); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + props.onSubmit(); + }; + + return ( + <> + + {m.verify.title} + + {fill(m.verify.description, { phoneNumber: stringToFormattedPhoneString(props.phoneNumber) })} + + + + } + > + + {m.verify.label} + ()} + name='code' + value={props.code} + onValueChange={props.onCodeChange} + onComplete={props.onSubmit} + /> + + {props.errorMessage} + + + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + ); +}