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
2 changes: 2 additions & 0 deletions .changeset/honest-chairs-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ describe('UserProfileAddEmailDialog', () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<VerificationExample onSubmit={onSubmit} />);
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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ describe('UserProfileAddPhoneDialog', () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<VerificationExample onSubmit={onSubmit} />);
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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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';
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';
Expand All @@ -32,16 +32,7 @@ export interface UserProfileAddEmailDialogProps {
}

export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) {
const emailFormId = useId();
const verifyFormId = useId();
const emailRef = useRef<HTMLInputElement>(null);
const verifyRef = useRef<HTMLDivElement>(null);
const [beforeSeconds, afterSeconds] = m.verify.resendCountdown.split('{seconds}');

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
props.onSubmit();
};

return (
<Dialog.Root
Expand All @@ -52,9 +43,7 @@ export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps)
{props.trigger ? <Dialog.Trigger render={props.trigger} /> : null}
<Dialog.Popup
size='card'
initialFocus={() =>
emailRef.current ?? verifyRef.current?.querySelector<HTMLInputElement>('input:not([type="hidden"])') ?? true
}
initialFocus={props.step === 'email' ? emailRef : undefined}
>
<Card.Root
elevation='overlay'
Expand All @@ -67,121 +56,27 @@ export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps)
{current => (
<>
<Flow.Step ids={['email']}>
<Card.Header>
<Card.Title>{m.email.title}</Card.Title>
<Card.Description>{m.email.description}</Card.Description>
</Card.Header>
<Card.Content
render={
<form
id={emailFormId}
onSubmit={handleSubmit}
/>
}
>
<Field.Root
required
disabled={current.isPending}
invalid={Boolean(current.errorMessage)}
>
<Field.Label>{m.email.label}</Field.Label>
<Input
ref={emailRef}
name='emailAddress'
type='email'
autoComplete='email'
value={current.emailAddress}
onChange={event => current.onEmailAddressChange(event.target.value)}
/>
{current.errorMessage ? <Field.Error>{current.errorMessage}</Field.Error> : null}
</Field.Root>
</Card.Content>
<Card.Footer>
<SubmitButton
form={emailFormId}
fullWidth
isPending={current.isPending}
pendingLabel={m.email.pending}
>
{m.email.submit}
</SubmitButton>
</Card.Footer>
<EnterEmailStep
inputRef={emailRef}
emailAddress={current.emailAddress}
onEmailAddressChange={current.onEmailAddressChange}
onSubmit={current.onSubmit}
isPending={current.isPending}
errorMessage={current.errorMessage}
/>
</Flow.Step>
<Flow.Step
ids={['verify']}
ref={verifyRef}
>
<Card.Header>
<Card.Title>{m.verify.title}</Card.Title>
<Card.Description>
{fill(m.verify.description, { emailAddress: current.emailAddress })}
</Card.Description>
</Card.Header>
<Card.Content
render={
<form
id={verifyFormId}
onSubmit={handleSubmit}
/>
}
>
<Field.Root
required
disabled={current.isPending}
invalid={Boolean(current.errorMessage)}
>
<Field.Label visuallyHidden>{m.verify.label}</Field.Label>
<Otp
name='code'
value={current.code}
onValueChange={current.onCodeChange}
onComplete={current.onSubmit}
/>
{current.errorMessage ? <Field.Error>{current.errorMessage}</Field.Error> : null}
<Button
type='button'
size='sm'
variant='link'
color='neutral'
disabled={current.isPending || current.isResending || (current.resendSeconds ?? 0) > 0}
onClick={current.onResend}
>
{current.isResending ? (
m.verify.resending
) : (current.resendSeconds ?? 0) > 0 ? (
<span>
{beforeSeconds}
<span {...stylex.props(styles.countdown)}>{current.resendSeconds}</span>
{afterSeconds}
</span>
) : (
m.verify.resend
)}
</Button>
</Field.Root>
</Card.Content>
<Card.Footer>
<Dialog.Close
render={
<Button
variant='outline'
color='neutral'
fullWidth
/>
}
>
{m.verify.cancel}
</Dialog.Close>
<SubmitButton
form={verifyFormId}
fullWidth
isPending={current.isPending}
disabled={current.isResending}
pendingLabel={m.verify.pending}
>
{m.verify.submit}
</SubmitButton>
</Card.Footer>
<Flow.Step ids={['verify']}>
<VerifyEmailStep
emailAddress={current.emailAddress}
code={current.code}
onCodeChange={current.onCodeChange}
onSubmit={current.onSubmit}
onResend={current.onResend}
isPending={current.isPending}
errorMessage={current.errorMessage}
isResending={current.isResending}
resendSeconds={current.resendSeconds}
/>
</Flow.Step>
</>
)}
Expand All @@ -191,3 +86,162 @@ export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps)
</Dialog.Root>
);
}

interface EnterEmailStepProps {
inputRef: Ref<HTMLInputElement>;
emailAddress: string;
onEmailAddressChange: (value: string) => void;
onSubmit: () => void;
isPending?: boolean;
errorMessage?: string;
}

function EnterEmailStep(props: EnterEmailStepProps) {
const emailFormId = useId();

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
props.onSubmit();
};

return (
<>
<Card.Header>
<Card.Title>{m.email.title}</Card.Title>
<Card.Description>{m.email.description}</Card.Description>
</Card.Header>
<Card.Content
render={
<form
id={emailFormId}
onSubmit={handleSubmit}
/>
}
>
<Field.Root
required
disabled={props.isPending}
invalid={Boolean(props.errorMessage)}
>
<Field.Label>{m.email.label}</Field.Label>
<Input
ref={props.inputRef}
name='emailAddress'
type='email'
autoComplete='email'
value={props.emailAddress}
onChange={event => props.onEmailAddressChange(event.target.value)}
/>
{props.errorMessage ? <Field.Error>{props.errorMessage}</Field.Error> : null}
</Field.Root>
</Card.Content>
<Card.Footer>
<SubmitButton
form={emailFormId}
fullWidth
isPending={props.isPending}
pendingLabel={m.email.pending}
>
{m.email.submit}
</SubmitButton>
</Card.Footer>
</>
);
}

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<HTMLFormElement>) => {
event.preventDefault();
props.onSubmit();
};

return (
<>
<Card.Header>
<Card.Title>{m.verify.title}</Card.Title>
<Card.Description>{fill(m.verify.description, { emailAddress: props.emailAddress })}</Card.Description>
</Card.Header>
<Card.Content
render={
<form
id={verifyFormId}
onSubmit={handleSubmit}
/>
}
>
<Field.Root
required
disabled={props.isPending}
invalid={Boolean(props.errorMessage)}
>
<Field.Label visuallyHidden>{m.verify.label}</Field.Label>
<Otp
ref={useFlowAutoFocus<HTMLInputElement>()}
name='code'
value={props.code}
onValueChange={props.onCodeChange}
onComplete={props.onSubmit}
/>
{props.errorMessage ? <Field.Error>{props.errorMessage}</Field.Error> : null}
<Button
type='button'
size='sm'
variant='link'
color='neutral'
disabled={props.isPending || props.isResending || (props.resendSeconds ?? 0) > 0}
onClick={props.onResend}
>
{props.isResending ? (
m.verify.resending
) : (props.resendSeconds ?? 0) > 0 ? (
<span>
{beforeSeconds}
<span {...stylex.props(styles.countdown)}>{props.resendSeconds}</span>
{afterSeconds}
</span>
) : (
m.verify.resend
)}
</Button>
</Field.Root>
</Card.Content>
<Card.Footer>
<Dialog.Close
render={
<Button
variant='outline'
color='neutral'
fullWidth
/>
}
>
{m.verify.cancel}
</Dialog.Close>
<SubmitButton
form={verifyFormId}
fullWidth
isPending={props.isPending}
disabled={props.isResending}
pendingLabel={m.verify.pending}
>
{m.verify.submit}
</SubmitButton>
</Card.Footer>
</>
);
}
Loading
Loading