diff --git a/src/components/ai-edition/LeftPanel.tooltips.test.tsx b/src/components/ai-edition/LeftPanel.tooltips.test.tsx index 9796e9b64..edd0ccdf2 100644 --- a/src/components/ai-edition/LeftPanel.tooltips.test.tsx +++ b/src/components/ai-edition/LeftPanel.tooltips.test.tsx @@ -115,6 +115,8 @@ function renderPanel() { /** Opens a control's tooltip the way the keyboard does (focus opens it at once) and returns what * it says and whether it shows a shortcut chip. */ async function tooltipOn(control: HTMLElement) { + // A keyboard focus: one the mouse gave opens no tooltip. + fireEvent.keyDown(window, { key: "Tab" }); act(() => control.focus()); const text = (await screen.findByRole("tooltip")).textContent; const chip = document.querySelector('[data-slot="tooltip-content"] kbd')?.textContent ?? null; diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index f357f1121..7c4eacabf 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -407,6 +407,8 @@ describe("EditorTopBar responsive affordances and tooltips", () => { // Every icon-only control names itself in a tooltip; undo and redo also show the key. async function tooltipOn(name: string) { const control = screen.getByRole("button", { name }); + // A keyboard focus: one the mouse gave opens no tooltip. + fireEvent.keyDown(window, { key: "Tab" }); act(() => control.focus()); const tooltip = await screen.findByRole("tooltip"); const visible = document.querySelector('[data-slot="tooltip-content"]'); diff --git a/src/components/ai-edition/v4/FloatingInspector.test.tsx b/src/components/ai-edition/v4/FloatingInspector.test.tsx index 512b2ab18..2bbe078bb 100644 --- a/src/components/ai-edition/v4/FloatingInspector.test.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.test.tsx @@ -112,6 +112,8 @@ describe("FloatingInspector", () => { async function tooltipOf(name: string) { const button = screen.getByRole("button", { name }); + // A keyboard focus: one the mouse gave opens no tooltip. + fireEvent.keyDown(window, { key: "Tab" }); act(() => button.focus()); await screen.findByRole("tooltip"); const visible = document.querySelector('[data-slot="tooltip-content"]'); @@ -159,6 +161,99 @@ describe("FloatingInspector", () => { expect(buttons.length).toBeGreaterThanOrEqual(5); for (const button of buttons) expect(button).not.toHaveAttribute("title"); }); + + it("names a rail button on hover, and Escape closes it", async () => { + render(); + fireEvent.pointerMove(screen.getByRole("button", { name: "settings.audio.title" })); + expect((await screen.findByRole("tooltip")).textContent).toBe("settings.facets.tips.audio"); + + fireEvent.keyDown(document.body, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + }); + + // Issue #1016: the focus comes back to the clicked button when the window is switched back + // to, and its tooltip opened then, over the panel and the next buttons, until a blur. + it("leaves no tooltip on a clicked rail button when the focus comes back to it", () => { + render(); + const audio = screen.getByRole("button", { name: "settings.audio.title" }); + fireEvent.pointerDown(audio); + fireEvent.mouseDown(audio); + act(() => audio.focus()); + fireEvent.pointerUp(audio); + fireEvent.mouseUp(audio); + fireEvent.click(audio); + act(() => { + audio.blur(); + audio.focus(); + }); + expect(audio).toHaveFocus(); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + }); + + // Issue #1006: drawn inside the stage, the list was clipped by the stage's edge and its last + // rows sat under the timeline, where no click reached them. + describe("clip picker", () => { + beforeEach(() => { + vi.stubGlobal("ResizeObserver", StubResizeObserver); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const threeClips = [0, 1, 2].map((i) => + clipSchema.parse({ + id: `c${i}`, + assetId: "a1", + sourceStartSec: i * 10, + sourceEndSec: i * 10 + 10, + timelineStartSec: i * 10, + timelineEndSec: i * 10 + 10, + origin: "user", + }), + ); + const editClip = () => screen.getByRole("button", { name: "editor.editClipDialog.title" }); + + it("opens outside the stage, and every row opens its clip", () => { + const onEditClip = vi.fn(); + const { container } = render( + , + ); + fireEvent.click(editClip()); + const menu = screen.getByRole("menu", { name: "editor.editClipDialog.pickClipTitle" }); + expect(container).not.toContainElement(menu); + expect(editClip()).toHaveAttribute("aria-expanded", "true"); + + const rows = within(menu).getAllByRole("menuitem"); + expect(rows).toHaveLength(3); + fireEvent.click(rows[2]); + expect(onEditClip).toHaveBeenCalledWith(threeClips[2]); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("closes on Escape without opening a clip", async () => { + const onEditClip = vi.fn(); + render(); + fireEvent.click(editClip()); + fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(onEditClip).not.toHaveBeenCalled(); + }); + + it("opens the only clip at once, with no menu", () => { + const onEditClip = vi.fn(); + render( + , + ); + expect(editClip()).not.toHaveAttribute("aria-haspopup"); + fireEvent.click(editClip()); + expect(onEditClip).toHaveBeenCalledWith(threeClips[0]); + expect(screen.queryByRole("menu")).toBeNull(); + }); }); it("renders collapse button with editor.inspector.collapseInspector and collapses inspector when clicked", () => { @@ -510,6 +605,8 @@ describe("FloatingInspector", () => { openProject(3); render(); const facet = await screen.findByRole("button", { name: "settings.cursor.title" }); + // A keyboard focus: one the mouse gave opens no tooltip. + fireEvent.keyDown(window, { key: "Tab" }); act(() => facet.focus()); expect((await screen.findByRole("tooltip")).textContent).toBe("settings.facets.tips.cursor"); expect(facet).not.toHaveAttribute("title"); diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 8b0bac86e..58bae545b 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -28,6 +28,7 @@ import { import type { ComponentProps } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { TOOLTIP_GAP_PX, Tooltip } from "@/components/ui/tooltip"; import { parseCustomPlaybackSpeedInput } from "@/components/video-editor/customPlaybackSpeed"; import { @@ -150,17 +151,6 @@ export function FloatingInspector({ }; const facet = facets.some(({ id }) => id === chosenFacet) ? chosenFacet : facets[0].id; const [clipPickerOpen, setClipPickerOpen] = useState(false); - const clipPickerRef = useRef(null); - useEffect(() => { - if (!clipPickerOpen) return; - const onDocMouseDown = (e: MouseEvent) => { - if (clipPickerRef.current && !clipPickerRef.current.contains(e.target as Node)) { - setClipPickerOpen(false); - } - }; - document.addEventListener("mousedown", onDocMouseDown); - return () => document.removeEventListener("mousedown", onDocMouseDown); - }, [clipPickerOpen]); const selection = tl.selection; // An imported audio track is selected (issue #350) — like a region selection it // takes over the inspector body with its own pane (see AudioTrackPane). @@ -203,34 +193,45 @@ export function FloatingInspector({ ))} -
+ {/* A portal, like the timeline's menus: drawn inside the stage, the list was clipped by + the stage's edge, and the rows past it sat under the timeline, out of reach. */} + 1} onOpenChange={setClipPickerOpen}> - + + + - {clipPickerOpen && clips.length > 1 ? ( + { + // The clip's dialog has the focus by then: handing it back to this button would + // leave it behind the dialog. + if (document.activeElement !== document.body) event.preventDefault(); + }} + >

))}

- ) : null} -
+ + ); diff --git a/src/components/ai-edition/v4/MediaStage.regenerate.test.tsx b/src/components/ai-edition/v4/MediaStage.regenerate.test.tsx index 35ed417ef..b57e4d550 100644 --- a/src/components/ai-edition/v4/MediaStage.regenerate.test.tsx +++ b/src/components/ai-edition/v4/MediaStage.regenerate.test.tsx @@ -77,6 +77,8 @@ describe("MediaStage Regenerate button", () => { it("asks for a new transcription, and says what it redoes in its tooltip", async () => { const regenerate = await openDetail(); expect(regenerate).not.toHaveAttribute("title"); + // A keyboard focus: one the mouse gave opens no tooltip. + fireEvent.keyDown(window, { key: "Tab" }); act(() => regenerate.focus()); expect((await screen.findByRole("tooltip")).textContent).toBe("mediaStage.regenerateTip"); fireEvent.click(regenerate); @@ -93,6 +95,8 @@ describe("MediaStage Regenerate button", () => { expect(regenerate).toHaveStyle({ opacity: "0.6", cursor: "not-allowed" }); expect(regenerate.querySelector("svg")).toHaveClass("animate-spin"); + // A keyboard focus: one the mouse gave opens no tooltip. + fireEvent.keyDown(window, { key: "Tab" }); act(() => regenerate.focus()); expect(regenerate).toHaveFocus(); expect((await screen.findByRole("tooltip")).textContent).toBe("mediaStage.regenerateTip"); diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index b79308df3..4b4d8f6f7 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -123,6 +123,8 @@ function pill(rowLabelKey: string) { /** Opens a pill's tooltip the way the keyboard does (focus opens it at once) and returns what * it says, or null when it opens nothing. */ async function tooltipOn(control: HTMLElement) { + // A keyboard focus: one the mouse gave opens no tooltip. + fireEvent.keyDown(window, { key: "Tab" }); act(() => control.focus()); try { const text = (await screen.findByRole("tooltip", {}, { timeout: 150 })).textContent; @@ -617,6 +619,8 @@ describe("RecStage names and tooltips", () => { await screen.findByText("rec.hideDesktopIcons"); for (const row of ["rec.systemAudio", "rec.editableCursor", "rec.hideDesktopIcons"]) { const control = pill(row); + // A keyboard focus: one the mouse gave opens no tooltip. + fireEvent.keyDown(window, { key: "Tab" }); act(() => control.focus()); await screen.findByRole("tooltip"); expect( diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index c3f98ca09..7fdd1aeef 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -943,6 +943,8 @@ describe("V4Timeline toolbar tooltips", () => { * returns the text and the chip of the visible copy. */ async function tooltipOn(name: string) { const control = screen.getByLabelText(name); + // A keyboard focus: one the mouse gave opens no tooltip. + fireEvent.keyDown(window, { key: "Tab" }); act(() => control.focus()); await screen.findByRole("tooltip"); const visible = document.querySelector('[data-slot="tooltip-content"]'); @@ -963,6 +965,8 @@ describe("V4Timeline toolbar tooltips", () => { "buttons.addSpeed", "buttons.addAnnotation", "buttons.addCameraFullscreen", + // Its menu also records a voiceover, but the key, like the lane's "Press M", adds audio. + "toolbar.addAudioTooltip", ]) { chips[name] = (await tooltipOn(name)).chip; // The name is the string alone: a "(Z)" in it would be a second, stale copy of the key. @@ -974,14 +978,16 @@ describe("V4Timeline toolbar tooltips", () => { "buttons.addSpeed": "S", "buttons.addAnnotation": "A", "buttons.addCameraFullscreen": "C", + "toolbar.addAudioTooltip": "M", }); }); - // The five creators are remappable, so the chip must follow the saved binding, not the default. + // The creators are remappable, so the chip must follow the saved binding, not the default. it("shows the key the user remapped, not the default", async () => { const getShortcuts = vi.fn(async () => ({ addZoom: { key: "q" }, addTrim: { key: "t", ctrl: true, shift: true }, + addAudio: { key: "b" }, })); (window as unknown as { electronAPI?: unknown }).electronAPI = { getShortcuts }; renderTimeline(); @@ -989,6 +995,7 @@ describe("V4Timeline toolbar tooltips", () => { await act(() => Promise.resolve()); expect((await tooltipOn("buttons.addZoom")).chip).toBe("Q"); expect((await tooltipOn("buttons.addTrim")).chip).toBe("Ctrl + Shift + T"); + expect((await tooltipOn("toolbar.addAudioTooltip")).chip).toBe("B"); // Unset actions keep their default. expect((await tooltipOn("buttons.addSpeed")).chip).toBe("S"); }); diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index a29b9c239..0d9d7f537 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -1918,7 +1918,10 @@ export function V4Timeline({ button's menu right next to it. */} {tool.id === "comment" ? ( - +