diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 62be5ecd5..9c3973930 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -119,10 +119,25 @@ flex-shrink: 0; transition: background var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease); } -.ghostBtn:hover { +.ghostBtn:hover:not(:disabled) { background: var(--surface-1); border-color: var(--border); } +.ghostBtn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.projectNameInput { + height: 30px; + min-width: 160px; + padding: 0 10px; + border-radius: 9px; + border: 1px solid var(--accent); + background: var(--surface); + color: var(--fg); + font: 500 13px var(--font-display); + outline: none; +} .saved { display: inline-flex; align-items: center; diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx new file mode 100644 index 000000000..0dade6d48 --- /dev/null +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -0,0 +1,122 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +// ProjectNameField is a private helper inside EditorTopBar, so reach it through +// the public topbar instead. The translator echoes keys; assertions read better +// against keys than against prose that drifts with copy edits. +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ locale: "en", setLocale: () => {} }), + useScopedT: () => (key: string) => key, +})); + +vi.mock("@/hooks/useTheme", () => ({ + useTheme: () => ({ theme: "dark", toggle: () => {} }), +})); + +import { EditorTopBar } from "./EditorTopBar"; + +const noop = () => {}; + +function renderTopBar(projectTitle: string | null) { + const onRename = vi.fn(); + render( + , + ); + return { onRename }; +} + +describe("ProjectNameField (issue #180)", () => { + it("renders the project title on the button", () => { + renderTopBar("Demo Project"); + expect(screen.getByRole("button", { name: "topbar.renameProject" })).toHaveTextContent( + "Demo Project", + ); + }); + + it("shows a placeholder and is disabled when no project is loaded", () => { + renderTopBar(null); + const button = screen.getByRole("button", { name: "topbar.renameProject" }); + expect(button).toBeDisabled(); + expect(button).toHaveTextContent("topbar.noProject"); + }); + + it("swaps to an input pre-filled with the title on click and selects it", () => { + renderTopBar("Demo Project"); + fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" })); + const input = screen.getByRole("textbox") as HTMLInputElement; + expect(input.value).toBe("Demo Project"); + // The text is selected on focus, so a keystroke replaces the whole title. + fireEvent.change(input, { target: { value: "Renamed" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(input).not.toBeInTheDocument(); + }); + + it("commits a typed title on Enter via onRename", () => { + const { onRename } = renderTopBar("Demo Project"); + fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" })); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "Renamed" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onRename).toHaveBeenCalledWith("Renamed"); + }); + + it("commits on blur when the title was edited", () => { + const { onRename } = renderTopBar("Demo Project"); + fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" })); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "Blurred rename" } }); + fireEvent.blur(input); + expect(onRename).toHaveBeenCalledWith("Blurred rename"); + }); + + it("rejects an empty / whitespace-only rename", () => { + const { onRename } = renderTopBar("Demo Project"); + fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" })); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: " " } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onRename).not.toHaveBeenCalled(); + }); + + it("cancels on Escape without calling onRename", () => { + const { onRename } = renderTopBar("Demo Project"); + fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" })); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "Half-typed" } }); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onRename).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "topbar.renameProject" })).toHaveTextContent( + "Demo Project", + ); + }); + + it("keeps the rename button in the no-drag region (regression for #180)", () => { + // The pre-fix button used `style={{ all: "unset" }}` which clobbered the + // topbar's `-webkit-app-region: no-drag` rule. In the Electron build the + // button then becomes a window-drag handle and the click never fires the + // onClick handler. The CSS module class must keep the no-drag property. + renderTopBar("Demo Project"); + const button = screen.getByRole("button", { name: "topbar.renameProject" }); + // jsdom doesn't honour `-webkit-app-region`, so assert the marker + // indirectly via the inline-style rule we removed: the pre-fix button + // had `all: unset`; if any element still has it, the regression is back. + expect(button.getAttribute("style") ?? "").not.toMatch(/all\s*:\s*unset/); + }); +}); diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx index 75ef8d260..f91a91be6 100644 --- a/src/components/ai-edition/v4/EditorTopBar.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.tsx @@ -205,26 +205,25 @@ function ProjectNameField({ const t = useScopedT("editor"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(title ?? ""); - const inputRef = useRef(null); - useEffect(() => { - if (editing) { - setDraft(title ?? ""); - inputRef.current?.select(); - } - }, [editing, title]); + const startEditing = () => { + setDraft(title ?? ""); + setEditing(true); + }; const commit = () => { setEditing(false); const next = draft.trim(); - if (next && next !== title) onRename(next); + if (next) onRename(next); }; if (editing) { return ( e.currentTarget.select()} onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { @@ -235,39 +234,20 @@ function ProjectNameField({ setEditing(false); } }} - style={{ - height: 30, - minWidth: 160, - padding: "0 10px", - borderRadius: 9, - border: "1px solid var(--accent)", - background: "var(--surface)", - color: "var(--fg)", - font: "500 13px var(--font-display)", - outline: "none", - }} /> ); } return ( - - - + ); }