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
32 changes: 32 additions & 0 deletions .changeset/tidy-lions-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@seamless-auth/react': minor
---

Stop painting the disabled submit button as a filled grey primary.

On the sign-in and MFA screens the submit button was disabled until its field
validated, and while disabled it was filled with `--seamless-disabled` while the
label kept `--seamless-accent-contrast`. Those two colours were chosen in
different places, so no value a themed app could supply worked in both a light
and a dark theme: lighten the fill and the label washed out, darken it and the
label went near-black on dark. The result read as a primary button that had
broken rather than a control waiting on input.

The disabled state is now the enabled button at reduced opacity. Label and
background stay on the accent pair the app already tuned, so their contrast
cannot invert with the theme, and the control reads as inactive instead of
broken. This matches how the magic-link and passkey screens already draw their
disabled buttons.

`--seamless-disabled` is no longer read anywhere and has been dropped from the
token table. If you set it, remove it; every other token behaves as before.

The sign-in screen now also says why the button is refusing, in a live region
below it that reports whether the field is empty, incomplete, or ready. A
disabled button is not focusable and is passed over by screen readers, so the
refusal was previously silent for the people least able to guess the reason.

Fixing that surfaced a related bug: a valid email typed in registration left the
Login button enabled after switching to sign-in, even with the identifier field
empty, because the submit check fell through to the registration field. Each
mode now checks only its own field.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -971,7 +971,6 @@ There is no provider prop or JavaScript API for this. Setting the variables is t
| `--seamless-border` | Input, button, and panel borders | `#4b5563`, `#d1d5db` |
| `--seamless-text` | Headings and body text | `white` |
| `--seamless-text-muted` | Labels, helper text, secondary copy | `#9ca3af`, `#d1d5db` |
| `--seamless-disabled` | Background of disabled submit buttons | `#9ca3af` |
| `--seamless-danger` | Error messages | `#f87171` |
| `--seamless-success` | Success messages and the verified check icon | `#34d399` |
| `--seamless-warning` | OTP countdown and resend timers | `#facc15` |
Expand All @@ -990,6 +989,10 @@ There is no provider prop or JavaScript API for this. Setting the variables is t
- Two decorative tints are deliberately not tokenised: the pulse ring behind the magic-link mail icon
and the disc behind the success check. Both are translucent and sit directly under an icon, so an
opaque override would hide the icon it is meant to frame.
- Disabled buttons are not a separate colour. They are the enabled button at reduced opacity, so the
label and its background always come from the same accent pair you set and the contrast between
them cannot invert when the theme changes. `--seamless-disabled` used to set a standalone grey fill
and no longer does anything; remove it from your overrides.
- The package ships one palette and no `prefers-color-scheme` rules. If you want the auth UI to follow
the system theme, wrap your own overrides in a media query.

Expand Down
18 changes: 15 additions & 3 deletions src/styles/login.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,32 @@
color: var(--seamless-accent-contrast, white);
padding: 0.5rem;
border-radius: 0.375rem;
transition: background-color 0.3s;
transition:
background-color 0.3s,
opacity 0.3s;
cursor: pointer;
margin-top: 1rem;
}

.button:hover {
.button:hover:not(:disabled) {
background-color: var(--seamless-accent-hover, #1d4ed8);
}

/* Fading the enabled pair keeps the label and its background on the same
accent the app already tuned, so a theme cannot invert one without the
other. A separate disabled fill could not do that. */
.button:disabled {
background-color: var(--seamless-disabled, #9ca3af);
opacity: 0.6;
cursor: not-allowed;
}

.submitHint {
font-size: 0.75rem;
color: var(--seamless-text-muted, #9ca3af);
margin-top: 0.5rem;
text-align: center;
}

.toggle {
margin-top: 1.5rem;
width: 100%;
Expand Down
6 changes: 4 additions & 2 deletions src/styles/mfaLogin.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,13 @@
color: var(--seamless-accent-contrast, white);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
transition:
background-color 0.2s,
opacity 0.2s;
}

.submit:disabled {
background-color: var(--seamless-disabled, #9ca3af);
opacity: 0.6;
cursor: not-allowed;
}

Expand Down
32 changes: 29 additions & 3 deletions src/views/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,33 @@ const Login: React.FC = () => {
setIdentifier(value);
};

const canSubmit = (): boolean | undefined => {
if (mode === 'login' && identifier) {
const canSubmit = (): boolean => {
if (mode === 'login') {
return isValidEmail(identifier) || isValidPhoneNumber(identifier);
}

// Registration only needs a valid email. A phone can be added later.
return isValidEmail(email);
};

// A disabled button is skipped by screen readers and explains nothing to
// anyone else, so the reason it is refusing lives in a live region instead.
const submitHint = (): string => {
if (mode === 'login') {
if (!identifier) return 'Enter your email or phone number to continue.';

return canSubmit()
? 'Ready to continue.'
: 'This does not look like a complete email or phone number yet.';
}

if (!email) return 'Enter your email address to continue.';

return canSubmit()
? 'Ready to continue.'
: 'This does not look like a complete email address yet.';
};

const register = async () => {
setFormErrors('');

Expand Down Expand Up @@ -237,9 +255,17 @@ const Login: React.FC = () => {
{emailError && <p className={styles.error}>{emailError}</p>}
</div>
)}
<button type="submit" className={styles.button} disabled={!canSubmit()}>
<button
type="submit"
className={styles.button}
disabled={!canSubmit()}
aria-describedby="seamless-submit-hint"
>
{mode === 'login' ? 'Login' : 'Register'}
</button>
<p id="seamless-submit-hint" role="status" className={styles.submitHint}>
{submitHint()}
</p>
{formErrors && <p className={styles.error}>{formErrors}</p>}
<button
type="button"
Expand Down
70 changes: 70 additions & 0 deletions tests/login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,76 @@ describe('Login', () => {
});
});

test('submit hint names the field to fill while the button is disabled', async () => {
(isValidEmail as jest.Mock).mockImplementation((value: string) =>
value.includes('@')
);

render(<Login />);

expect(await screen.findByRole('status')).toHaveTextContent(
'Enter your email or phone number to continue.'
);
expect(screen.getByRole('button', { name: /^login$/i })).toBeDisabled();
});

test('submit hint reports an incomplete entry as the user types', async () => {
(isValidEmail as jest.Mock).mockImplementation((value: string) =>
value.includes('@')
);

render(<Login />);

fireEvent.change(screen.getByPlaceholderText(/email or phone number/i), {
target: { value: 'nope' },
});

expect(await screen.findByRole('status')).toHaveTextContent(
'This does not look like a complete email or phone number yet.'
);
expect(screen.getByRole('button', { name: /^login$/i })).toBeDisabled();
});

test('submit hint confirms readiness once the button enables', async () => {
(isValidEmail as jest.Mock).mockImplementation((value: string) =>
value.includes('@')
);

render(<Login />);

fireEvent.change(screen.getByPlaceholderText(/email or phone number/i), {
target: { value: 'test@example.com' },
});

expect(await screen.findByRole('status')).toHaveTextContent('Ready to continue.');
expect(screen.getByRole('button', { name: /^login$/i })).toBeEnabled();
});

test('a valid register email does not enable submit after switching to login', async () => {
(isValidEmail as jest.Mock).mockImplementation((value: string) =>
value.includes('@')
);

render(<Login />);

fireEvent.click(screen.getByText(/don't have an account/i));
fireEvent.change(screen.getByLabelText(/email address/i), {
target: { value: 'test@example.com' },
});

await waitFor(() => {
expect(screen.getByRole('button', { name: /register/i })).toBeEnabled();
});

fireEvent.click(screen.getByText(/already have an account/i));

// The email typed in register mode is not the identifier login submits.
expect(screen.getByRole('button', { name: /^login$/i })).toBeDisabled();
expect(await screen.findByRole('status')).toHaveTextContent(
'Enter your email or phone number to continue.'
);
});

test('register mode submits with only an email', async () => {
(isValidEmail as jest.Mock).mockReturnValue(true);

Expand Down
Loading