From e25220bfab52a2d754988fe8d1619ff9590bc187 Mon Sep 17 00:00:00 2001 From: Liam Neville Date: Thu, 19 Feb 2026 12:28:48 +0300 Subject: [PATCH] feat(login): add autoFocus to input components (#11566) # 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 --- .../components/change-password-form.test.tsx | 45 ++++++++++++++ .../src/components/change-password-form.tsx | 1 + .../src/components/device-code-form.test.tsx | 24 ++++++++ .../login/src/components/device-code-form.tsx | 1 + apps/login/src/components/input.test.tsx | 6 ++ .../ldap-username-password-form.test.tsx | 26 ++++++++ .../ldap-username-password-form.tsx | 1 + apps/login/src/components/login-otp.test.tsx | 31 ++++++++++ apps/login/src/components/login-otp.tsx | 1 + .../src/components/password-form.test.tsx | 30 ++++++++++ apps/login/src/components/password-form.tsx | 1 + .../register-form-idp-incomplete.test.tsx | 44 ++++++++++++++ .../register-form-idp-incomplete.tsx | 2 + .../src/components/register-form.test.tsx | 38 ++++++++++++ apps/login/src/components/register-form.tsx | 1 + .../src/components/set-password-form.test.tsx | 59 +++++++++++++++++++ .../src/components/set-password-form.tsx | 2 + .../set-register-password-form.test.tsx | 46 +++++++++++++++ .../components/set-register-password-form.tsx | 1 + .../src/components/totp-register.test.tsx | 35 +++++++++++ apps/login/src/components/totp-register.tsx | 1 + .../src/components/username-form.test.tsx | 32 ++++++++++ apps/login/src/components/username-form.tsx | 1 + .../login/src/components/verify-form.test.tsx | 9 +++ apps/login/src/components/verify-form.tsx | 1 + 25 files changed, 439 insertions(+) create mode 100644 apps/login/src/components/change-password-form.test.tsx create mode 100644 apps/login/src/components/device-code-form.test.tsx create mode 100644 apps/login/src/components/ldap-username-password-form.test.tsx create mode 100644 apps/login/src/components/login-otp.test.tsx create mode 100644 apps/login/src/components/password-form.test.tsx create mode 100644 apps/login/src/components/register-form-idp-incomplete.test.tsx create mode 100644 apps/login/src/components/register-form.test.tsx create mode 100644 apps/login/src/components/set-password-form.test.tsx create mode 100644 apps/login/src/components/set-register-password-form.test.tsx create mode 100644 apps/login/src/components/totp-register.test.tsx create mode 100644 apps/login/src/components/username-form.test.tsx diff --git a/apps/login/src/components/change-password-form.test.tsx b/apps/login/src/components/change-password-form.test.tsx new file mode 100644 index 000000000..36271fd3a --- /dev/null +++ b/apps/login/src/components/change-password-form.test.tsx @@ -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( + , + ); + expect(getByTestId("password-change-current-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx index c8d1aff2b..cdcec0142 100644 --- a/apps/login/src/components/change-password-form.tsx +++ b/apps/login/src/components/change-password-form.tsx @@ -129,6 +129,7 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi ({ + 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(); + expect(getByTestId("code-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/device-code-form.tsx b/apps/login/src/components/device-code-form.tsx index 12a44fd59..1e64190b8 100644 --- a/apps/login/src/components/device-code-form.tsx +++ b/apps/login/src/components/device-code-form.tsx @@ -65,6 +65,7 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) { { expect(input?.maxLength).toBe(10); expect(input?.type).toBe("email"); }); + + it("should support autoFocus", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input).toHaveFocus(); + }); }); describe("Styling", () => { diff --git a/apps/login/src/components/ldap-username-password-form.test.tsx b/apps/login/src/components/ldap-username-password-form.test.tsx new file mode 100644 index 000000000..0d2dce091 --- /dev/null +++ b/apps/login/src/components/ldap-username-password-form.test.tsx @@ -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( + , + ); + expect(getByTestId("username-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/ldap-username-password-form.tsx b/apps/login/src/components/ldap-username-password-form.tsx index 669c673f7..342ae4b63 100644 --- a/apps/login/src/components/ldap-username-password-form.tsx +++ b/apps/login/src/components/ldap-username-password-form.tsx @@ -68,6 +68,7 @@ export function LDAPUsernamePasswordForm({ idpId, link }: Props) { ({ + 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( + , + ); + expect(getByTestId("code-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/login-otp.tsx b/apps/login/src/components/login-otp.tsx index 2bf7f49ee..4c4f0fa94 100644 --- a/apps/login/src/components/login-otp.tsx +++ b/apps/login/src/components/login-otp.tsx @@ -241,6 +241,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
({ + 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( + , + ); + expect(getByTestId("password-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/password-form.tsx b/apps/login/src/components/password-form.tsx index 7f767461d..23f66e91a 100644 --- a/apps/login/src/components/password-form.tsx +++ b/apps/login/src/components/password-form.tsx @@ -116,6 +116,7 @@ export function PasswordForm({ loginSettings, loginName, organization, defaultOr ({ + 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( + , + ); + expect(getByTestId("username-text-input")).toHaveFocus(); + }); + + test("should autofocus the firstname input when idpUserName is provided", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("firstname-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/register-form-idp-incomplete.tsx b/apps/login/src/components/register-form-idp-incomplete.tsx index d405ec1a2..318869532 100644 --- a/apps/login/src/components/register-form-idp-incomplete.tsx +++ b/apps/login/src/components/register-form-idp-incomplete.tsx @@ -100,6 +100,7 @@ export function RegisterFormIDPIncomplete({ ({ + 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( + , + ); + expect(getByTestId("firstname-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/register-form.tsx b/apps/login/src/components/register-form.tsx index 7352d5cfd..cd02c6b9d 100644 --- a/apps/login/src/components/register-form.tsx +++ b/apps/login/src/components/register-form.tsx @@ -122,6 +122,7 @@ export function RegisterForm({ ({ + 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( + , + ); + expect(getByTestId("code-text-input")).toHaveFocus(); + }); + + test("should autofocus the password input when codeRequired is false", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("password-set-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/set-password-form.tsx b/apps/login/src/components/set-password-form.tsx index 84466d4c0..25742106b 100644 --- a/apps/login/src/components/set-password-form.tsx +++ b/apps/login/src/components/set-password-form.tsx @@ -201,6 +201,7 @@ export function SetPasswordForm({
({ + 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( + , + ); + expect(getByTestId("password-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/set-register-password-form.tsx b/apps/login/src/components/set-register-password-form.tsx index 56ddd443c..debcc4f2d 100644 --- a/apps/login/src/components/set-register-password-form.tsx +++ b/apps/login/src/components/set-register-password-form.tsx @@ -106,6 +106,7 @@ export function SetRegisterPasswordForm({ ({ + 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 }) => , +})); + +describe("TotpRegister", () => { + afterEach(cleanup); + + test("should autofocus the code input on mount", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("code-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/totp-register.tsx b/apps/login/src/components/totp-register.tsx index 8a6424689..3a268ec6b 100644 --- a/apps/login/src/components/totp-register.tsx +++ b/apps/login/src/components/totp-register.tsx @@ -116,6 +116,7 @@ export function TotpRegister({ uri, loginName, sessionId, requestId, organizatio
({ + 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( + , + ); + expect(getByTestId("username-text-input")).toHaveFocus(); + }); +}); diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index c98131c54..08ed561ec 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -101,6 +101,7 @@ export function UsernameForm({ { afterEach(cleanup); + describe("Input Focus", () => { + test("should autofocus the code input on mount", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("code-text-input")).toHaveFocus(); + }); + }); + describe("Auto-submit Behavior", () => { test("should call sendVerification automatically when submit=true", async () => { render(); diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 5d303a8c4..3fd53cb07 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -138,6 +138,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i