collective

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

diff --git a/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx b/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx--- a/frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx+++ b/frontend/libs/components/login-modal/src/lib/login-modal.spec.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();diff --git a/frontend/libs/components/login-modal/src/lib/login-modal.tsx b/frontend/libs/components/login-modal/src/lib/login-modal.tsx--- a/frontend/libs/components/login-modal/src/lib/login-modal.tsx+++ b/frontend/libs/components/login-modal/src/lib/login-modal.tsx@@ -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>   ); }