feat(login): add autoFocus to input components (#11566)

<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

# Which Problems Are Solved

The first input field on login screens is not automatically focused,
requiring users to manually click before typing. This creates
unnecessary friction during the login flow, particularly for
keyboard-oriented users and accessibility scenarios.


# How the Problems Are Solved

- Added the `autoFocus` prop to the first `TextInput` in all 12 login
form components.
- For forms with conditionally rendered first inputs
(`set-password-form` and `register-form-idp-incomplete`), `autoFocus` is
set dynamically so the correct field receives focus depending on which
input is visible.

# Additional Changes

None

# Additional Context

- Closes #11565

Co-authored-by: Max Peintner <[email protected]>
This commit is contained in:
Liam Neville
2026-02-19 09:28:48 +00:00
committed by GitHub
co-authored by Max Peintner
parent 9dd2773e80
commit e25220bfab
25 changed files with 439 additions and 0 deletions
@@ -0,0 +1,45 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ChangePasswordForm } from "./change-password-form";
import { create } from "@zitadel/client";
import { PasswordComplexitySettingsSchema } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/password", () => ({
checkSessionAndSetPassword: vi.fn(),
sendPassword: vi.fn(),
}));
vi.mock("@/lib/client", () => ({
handleServerActionResponse: vi.fn(),
}));
const defaultComplexitySettings = create(PasswordComplexitySettingsSchema, {
minLength: 8n,
requiresUppercase: false,
requiresLowercase: false,
requiresNumber: false,
requiresSymbol: false,
});
describe("ChangePasswordForm", () => {
afterEach(cleanup);
test("should autofocus the current password input on mount", () => {
const { getByTestId } = render(
<ChangePasswordForm
passwordComplexitySettings={defaultComplexitySettings}
sessionId="session-1"
loginName="[email protected]"
/>,
);
expect(getByTestId("password-change-current-text-input")).toHaveFocus();
});
});
@@ -129,6 +129,7 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi
<TextInput
type="password"
autoComplete="current-password"
autoFocus
required
{...register("currentPassword", {
required: t("change.required.currentPassword"),
@@ -0,0 +1,24 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DeviceCodeForm } from "./device-code-form";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/oidc", () => ({
getDeviceAuthorizationRequest: vi.fn(),
}));
describe("DeviceCodeForm", () => {
afterEach(cleanup);
test("should autofocus the code input on mount", () => {
const { getByTestId } = render(<DeviceCodeForm />);
expect(getByTestId("code-text-input")).toHaveFocus();
});
});
@@ -65,6 +65,7 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) {
<TextInput
type="text"
autoComplete="one-time-code"
autoFocus
{...register("userCode", { required: t("usercode.required.code") })}
label={t("usercode.labels.code")}
data-testid="code-text-input"
+6
View File
@@ -125,6 +125,12 @@ describe("TextInput Component", () => {
expect(input?.maxLength).toBe(10);
expect(input?.type).toBe("email");
});
it("should support autoFocus", () => {
const { container } = render(<TextInput label="Test" autoFocus />);
const input = container.querySelector("input");
expect(input).toHaveFocus();
});
});
describe("Styling", () => {
@@ -0,0 +1,26 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { LDAPUsernamePasswordForm } from "./ldap-username-password-form";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/idp", () => ({
createNewSessionForLDAP: vi.fn(),
}));
describe("LDAPUsernamePasswordForm", () => {
afterEach(cleanup);
test("should autofocus the username input on mount", () => {
const { getByTestId } = render(
<LDAPUsernamePasswordForm idpId="idp-1" link={false} />,
);
expect(getByTestId("username-text-input")).toHaveFocus();
});
});
@@ -68,6 +68,7 @@ export function LDAPUsernamePasswordForm({ idpId, link }: Props) {
<TextInput
type="text"
autoComplete="username"
autoFocus
{...register("loginName", { required: t("required.username") })}
label={t("labels.username")}
data-testid="username-text-input"
@@ -0,0 +1,31 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { LoginOTP } from "./login-otp";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/session", () => ({
updateOrCreateSession: vi.fn(),
}));
vi.mock("@/lib/client", () => ({
handleServerActionResponse: vi.fn(),
completeFlowOrGetUrl: vi.fn(),
}));
describe("LoginOTP", () => {
afterEach(cleanup);
test("should autofocus the code input on mount", () => {
const { getByTestId } = render(
<LoginOTP host={null} method="time-based" />,
);
expect(getByTestId("code-text-input")).toHaveFocus();
});
});
+1
View File
@@ -241,6 +241,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
<div className="mt-4">
<TextInput
type="text"
autoFocus
{...register("code", { required: t("verify.required.code") })}
label={t("verify.labels.code")}
autoComplete="one-time-code"
@@ -0,0 +1,30 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PasswordForm } from "./password-form";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/password", () => ({
sendPassword: vi.fn(),
resetPassword: vi.fn(),
}));
describe("PasswordForm", () => {
afterEach(cleanup);
test("should autofocus the password input on mount", () => {
const { getByTestId } = render(
<PasswordForm
loginSettings={undefined}
loginName="[email protected]"
/>,
);
expect(getByTestId("password-text-input")).toHaveFocus();
});
});
@@ -116,6 +116,7 @@ export function PasswordForm({ loginSettings, loginName, organization, defaultOr
<TextInput
type="password"
autoComplete="password"
autoFocus
{...register("password", { required: t("verify.required.password") })}
label={t("verify.labels.password")}
data-testid="password-text-input"
@@ -0,0 +1,44 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { RegisterFormIDPIncomplete } from "./register-form-idp-incomplete";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/register", () => ({
registerUserAndLinkToIDP: vi.fn(),
}));
vi.mock("@/lib/client", () => ({
handleServerActionResponse: vi.fn(),
}));
const defaultProps = {
organization: "org-1",
idpIntent: { idpIntentId: "intent-1", idpIntentToken: "token-1" },
idpUserId: "user-1",
idpId: "idp-1",
};
describe("RegisterFormIDPIncomplete", () => {
afterEach(cleanup);
test("should autofocus the username input when idpUserName is not provided", () => {
const { getByTestId } = render(
<RegisterFormIDPIncomplete {...defaultProps} />,
);
expect(getByTestId("username-text-input")).toHaveFocus();
});
test("should autofocus the firstname input when idpUserName is provided", () => {
const { getByTestId } = render(
<RegisterFormIDPIncomplete {...defaultProps} idpUserName="existing-user" />,
);
expect(getByTestId("firstname-text-input")).toHaveFocus();
});
});
@@ -100,6 +100,7 @@ export function RegisterFormIDPIncomplete({
<TextInput
type="text"
autoComplete="username"
autoFocus
required
{...register("username", { required: "Username is required" })}
label="Username"
@@ -113,6 +114,7 @@ export function RegisterFormIDPIncomplete({
<TextInput
type="firstname"
autoComplete="firstname"
autoFocus={!!idpUserName}
required
{...register("firstname", { required: t("required.firstname") })}
label={t("labels.firstname")}
@@ -0,0 +1,38 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { RegisterForm } from "./register-form";
import { create } from "@zitadel/client";
import { LegalAndSupportSettingsSchema } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/register", () => ({
registerUser: vi.fn(),
}));
vi.mock("@/lib/client", () => ({
handleServerActionResponse: vi.fn(),
}));
const defaultLegal = create(LegalAndSupportSettingsSchema, {});
describe("RegisterForm", () => {
afterEach(cleanup);
test("should autofocus the firstname input on mount", () => {
const { getByTestId } = render(
<RegisterForm
legal={defaultLegal}
organization="org-1"
idpCount={0}
/>,
);
expect(getByTestId("firstname-text-input")).toHaveFocus();
});
});
@@ -122,6 +122,7 @@ export function RegisterForm({
<TextInput
type="firstname"
autoComplete="firstname"
autoFocus
required
{...register("firstname", { required: t("required.firstname") })}
label={t("labels.firstname")}
@@ -0,0 +1,59 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SetPasswordForm } from "./set-password-form";
import { create } from "@zitadel/client";
import { PasswordComplexitySettingsSchema } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/password", () => ({
changePassword: vi.fn(),
resetPassword: vi.fn(),
sendPassword: vi.fn(),
}));
vi.mock("@/lib/client", () => ({
handleServerActionResponse: vi.fn(),
}));
const defaultComplexitySettings = create(PasswordComplexitySettingsSchema, {
minLength: 8n,
requiresUppercase: false,
requiresLowercase: false,
requiresNumber: false,
requiresSymbol: false,
});
describe("SetPasswordForm", () => {
afterEach(cleanup);
test("should autofocus the code input when codeRequired is true", () => {
const { getByTestId } = render(
<SetPasswordForm
passwordComplexitySettings={defaultComplexitySettings}
loginName="[email protected]"
userId="user-1"
codeRequired={true}
/>,
);
expect(getByTestId("code-text-input")).toHaveFocus();
});
test("should autofocus the password input when codeRequired is false", () => {
const { getByTestId } = render(
<SetPasswordForm
passwordComplexitySettings={defaultComplexitySettings}
loginName="[email protected]"
userId="user-1"
codeRequired={false}
/>,
);
expect(getByTestId("password-set-text-input")).toHaveFocus();
});
});
@@ -201,6 +201,7 @@ export function SetPasswordForm({
<div>
<TextInput
type="text"
autoFocus
required
{...register("code", {
required: t("set.required.code"),
@@ -216,6 +217,7 @@ export function SetPasswordForm({
<TextInput
type="password"
autoComplete="new-password"
autoFocus={!codeRequired}
required
{...register("password", {
required: t("set.required.newPassword"),
@@ -0,0 +1,46 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SetRegisterPasswordForm } from "./set-register-password-form";
import { create } from "@zitadel/client";
import { PasswordComplexitySettingsSchema } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/register", () => ({
registerUser: vi.fn(),
}));
vi.mock("@/lib/client", () => ({
handleServerActionResponse: vi.fn(),
}));
const defaultComplexitySettings = create(PasswordComplexitySettingsSchema, {
minLength: 8n,
requiresUppercase: false,
requiresLowercase: false,
requiresNumber: false,
requiresSymbol: false,
});
describe("SetRegisterPasswordForm", () => {
afterEach(cleanup);
test("should autofocus the password input on mount", () => {
const { getByTestId } = render(
<SetRegisterPasswordForm
passwordComplexitySettings={defaultComplexitySettings}
email="[email protected]"
firstname="Test"
lastname="User"
organization="org-1"
/>,
);
expect(getByTestId("password-text-input")).toHaveFocus();
});
});
@@ -106,6 +106,7 @@ export function SetRegisterPasswordForm({
<TextInput
type="password"
autoComplete="new-password"
autoFocus
required
{...register("password", {
required: t("password.required.password"),
@@ -0,0 +1,35 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TotpRegister } from "./totp-register";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/verify", () => ({
verifyTOTP: vi.fn(),
}));
vi.mock("@/lib/client", () => ({
handleServerActionResponse: vi.fn(),
completeFlowOrGetUrl: vi.fn(),
}));
vi.mock("qrcode.react", () => ({
QRCodeSVG: ({ value }: { value: string }) => <svg data-testid="qr-code" data-value={value} />,
}));
describe("TotpRegister", () => {
afterEach(cleanup);
test("should autofocus the code input on mount", () => {
const { getByTestId } = render(
<TotpRegister uri="otpauth://totp/test" secret="SECRET" />,
);
expect(getByTestId("code-text-input")).toHaveFocus();
});
});
@@ -116,6 +116,7 @@ export function TotpRegister({ uri, loginName, sessionId, requestId, organizatio
<div className="">
<TextInput
type="text"
autoFocus
{...register("code", { required: t("set.required.code") })}
label={t("set.labels.code")}
data-testid="code-text-input"
@@ -0,0 +1,32 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { UsernameForm } from "./username-form";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/lib/server/loginname", () => ({
sendLoginname: vi.fn(),
}));
describe("UsernameForm", () => {
afterEach(cleanup);
test("should autofocus the loginName input on mount", () => {
const { getByTestId } = render(
<UsernameForm
loginName=""
requestId={undefined}
loginSettings={undefined}
submit={false}
allowRegister={false}
/>,
);
expect(getByTestId("username-text-input")).toHaveFocus();
});
});
@@ -101,6 +101,7 @@ export function UsernameForm({
<TextInput
type="text"
autoComplete="username"
autoFocus
{...register("loginName", { required: t("required.loginName") })}
label={inputLabel}
data-testid="username-text-input"
@@ -28,6 +28,15 @@ describe("VerifyForm", () => {
afterEach(cleanup);
describe("Input Focus", () => {
test("should autofocus the code input on mount", () => {
const { getByTestId } = render(
<VerifyForm userId="user-1" code="" isInvite={false} submit={false} />,
);
expect(getByTestId("code-text-input")).toHaveFocus();
});
});
describe("Auto-submit Behavior", () => {
test("should call sendVerification automatically when submit=true", async () => {
render(<VerifyForm userId="user-1" code="123456" isInvite={false} submit={true} />);
@@ -138,6 +138,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
<TextInput
type="text"
autoComplete="one-time-code"
autoFocus
{...register("code", { required: t("verify.required.code") })}
label={t("verify.labels.code")}
data-testid="code-text-input"