mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-26 17:22:15 +00:00
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:
co-authored by
Max Peintner
parent
9dd2773e80
commit
e25220bfab
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user