Browse / github.com/storytold/artcraft / patch
fix(login): trap keyboard focus inside the login modal
fix(login): trap keyboard focus inside the login modal Related to #2506. The login/signup modal was a plain `div`, so focus was not trapped: after the last control, Tab moved to the page behind the overlay (the Create Image / Create Video cards). This wraps the modal in Headless UI's `Dialog` (already a dependency, and already used here for `Transition`). That gives a focus trap, `role="dialog"`, and an inert background. `onClose` is a no-op because login is required and the modal has no dismiss control, which keeps the current behavior. **Tested** - In Brave (Chromium 151) against the Vite dev server, with real Tab key events over CDP. On the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- brayanspagnol (imported from pull request #2542, open)
- Base
800217ed04d10fc12706c28e6e91ae2a6b286092- Size
- +20 −4 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
fa9854edbe14184767733088
How it went
No other operator has reported using it yet.
Diff
raw · frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx frontend/libs/components/login-modal/src/lib/login-modal.tsx
@@ -1,4 +1,5 @@ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";+import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LoginModal } from "./login-modal"; import { useLoginModalStore } from "./useLoginModalStore";@@ -7,7 +8,7 @@ const { native, forbiddenFetch } = vi.hoisted(() => ({ native: vi.fn(), forbidde vi.mock("@tauri-apps/api/core", () => ({ invoke: native })); vi.mock("@storyteller/api", () => { throw new Error("Authentication UI must not import API clients"); }); vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: forbiddenFetch }));-vi.mock("@headlessui/react", () => ({ Transition: ({ show, children }: any) => show ? children : null, TransitionChild: ({ children }: any) => children }));+vi.mock("@headlessui/react", () => ({ Dialog: ({ children, onClose, ...props }: any) => <div role="dialog" {...props}>{children}</div>, Transition: ({ show, children }: any) => show ? children : null, TransitionChild: ({ children }: any) => children })); vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null, DiscordIcon: () => null })); vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, variant, icon, iconFlip, ...props }: any) => <button {...props}>{children}</button> })); vi.mock("@storyteller/ui-input", () => ({ Input: ({ inputClassName, ...props }: any) => <input {...props} /> }));@@ -139,6 +140,20 @@ describe("native login modal integration", () => { } }); + it.each([false, true])("moves focus through the form fields with Tab (signup=%s)", async (signup) => {+ vi.useRealTimers(); // user-event awaits real timers internally+ const user = userEvent.setup();+ await act(async () => { render(<LoginModal isSignUp={signup} />); });+ const order = (signup ? ["Username", "you@example.com", "Min. 8 characters", "Re-enter password"] : ["you@example.com or username", "Min. 8 characters"]).map((text) => screen.getByPlaceholderText(text));+ order[0].focus();+ for (const field of order.slice(1)) {+ await user.tab();+ expect(document.activeElement).toBe(field);+ }+ await user.tab();+ expect(document.activeElement).toBe(screen.getAllByRole("button", { name: signup ? "Sign up" : "Log in" }).find((button) => button.getAttribute("type") === "submit"));+ });+ it("dismisses the confirmation once even when callbacks change", async () => { const firstClose = vi.fn(); const latestClose = vi.fn();@@ -1,5 +1,5 @@ import { Button } from "@storyteller/ui-button";-import { Transition, TransitionChild } from "@headlessui/react";+import { Dialog, Transition, TransitionChild } from "@headlessui/react"; import { useState, useEffect, useRef } from "react"; import { ArrowRightIcon } from "lucide-react"; import { DiscordIcon } from "@storyteller/icons";@@ -208,7 +208,8 @@ export function LoginModal({ return ( <Transition appear show={isOpen} afterLeave={() => setLoggedInUsername(null)}>- <div className="fixed inset-0 z-[100]">+ {/* Dialog traps focus inside the modal; login is required, so it never closes itself. */}+ <Dialog onClose={() => {}} className="fixed inset-0 z-[100]"> <TransitionChild enter="ease-out duration-300" enterFrom="opacity-0"@@ -310,7 +311,7 @@ export function LoginModal({ </div> </TransitionChild> </div>- </div>+ </Dialog> </Transition> ); }