Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/ai-edition/LeftPanel.tooltips.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/components/ai-edition/v4/EditorTopBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand Down
97 changes: 97 additions & 0 deletions src/components/ai-edition/v4/FloatingInspector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('[data-slot="tooltip-content"]');
Expand Down Expand Up @@ -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(<FloatingInspector {...defaultProps} clips={oneClip} />);
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(<FloatingInspector {...defaultProps} clips={oneClip} />);
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(
<FloatingInspector {...defaultProps} clips={threeClips} onEditClip={onEditClip} />,
);
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(<FloatingInspector {...defaultProps} clips={threeClips} onEditClip={onEditClip} />);
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(
<FloatingInspector
{...defaultProps}
clips={threeClips.slice(0, 1)}
onEditClip={onEditClip}
/>,
);
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", () => {
Expand Down Expand Up @@ -510,6 +605,8 @@ describe("FloatingInspector", () => {
openProject(3);
render(<FloatingInspector {...defaultProps} />);
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");
Expand Down
72 changes: 36 additions & 36 deletions src/components/ai-edition/v4/FloatingInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<HTMLDivElement | null>(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).
Expand Down Expand Up @@ -203,34 +193,45 @@ export function FloatingInspector({
</button>
</Tooltip>
))}
<div ref={clipPickerRef} style={{ position: "relative" }}>
{/* 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. */}
<Popover open={clipPickerOpen && clips.length > 1} onOpenChange={setClipPickerOpen}>
<Tooltip content={te("inspector.editClipTip")} {...RAIL_TOOLTIP}>
<button
type="button"
aria-label={te("editClipDialog.title")}
aria-haspopup={clips.length > 1 ? "menu" : undefined}
aria-expanded={clips.length > 1 ? clipPickerOpen : undefined}
onClick={() => {
if (selection) tl.clearSelection();
if (clips.length === 0) return;
if (clips.length === 1) {
onEditClip(clips[0]);
return;
}
setClipPickerOpen((v) => !v);
}}
>
<Pencil size={17} />
</button>
<PopoverTrigger asChild>
<button
type="button"
aria-label={te("editClipDialog.title")}
aria-haspopup={clips.length > 1 ? "menu" : undefined}
aria-expanded={clips.length > 1 ? clipPickerOpen : undefined}
onClick={(event) => {
if (selection) tl.clearSelection();
if (clips.length > 1) return;
// One clip or none: no menu to open (Radix skips a prevented click).
event.preventDefault();
if (clips.length === 1) onEditClip(clips[0]);
}}
>
<Pencil size={17} />
</button>
</PopoverTrigger>
</Tooltip>
{clipPickerOpen && clips.length > 1 ? (
<PopoverContent
side="left"
align="start"
sideOffset={RAIL_TOOLTIP.sideOffset}
collisionPadding={12}
animated={false}
className="w-auto border-0 bg-transparent p-0 shadow-none"
onCloseAutoFocus={(event) => {
// 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();
}}
>
<div
role="menu"
aria-label={te("editClipDialog.pickClipTitle")}
style={{
position: "absolute",
top: 0,
right: "calc(100% + 8px)",
minWidth: 200,
maxHeight: 320,
overflowY: "auto",
Expand All @@ -240,7 +241,6 @@ export function FloatingInspector({
boxShadow: "var(--elev-pop)",
backdropFilter: "blur(18px)",
padding: 6,
zIndex: 30,
}}
>
<p
Expand Down Expand Up @@ -290,8 +290,8 @@ export function FloatingInspector({
</button>
))}
</div>
) : null}
</div>
</PopoverContent>
</Popover>
</div>
</div>
);
Expand Down
4 changes: 4 additions & 0 deletions src/components/ai-edition/v4/MediaStage.regenerate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions src/components/ai-edition/v4/RecStage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand All @@ -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.
Expand All @@ -974,21 +978,24 @@ 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();
await vi.waitFor(() => expect(getShortcuts).toHaveBeenCalled());
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");
});
Expand Down
5 changes: 4 additions & 1 deletion src/components/ai-edition/v4/V4Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1918,7 +1918,10 @@ export function V4Timeline({
button's menu right next to it. */}
{tool.id === "comment" ? (
<Popover open={audioMenuOpen} onOpenChange={setAudioMenuOpen}>
<Tooltip content={t("toolbar.addAudioTooltip")}>
<Tooltip
content={t("toolbar.addAudioTooltip")}
shortcut={formatBinding(shortcuts.addAudio, isMac)}
>
<PopoverTrigger asChild>
<button
type="button"
Expand Down
Loading
Loading