Skip to content

Commit 28708c3

Browse files
committed
fix(editor): keep busy buttons hoverable and open Record mode tooltips above the pill
Regenerate, Rename and Auto-enhance use aria-disabled instead of disabled while busy, so their tooltip still opens and the click does nothing. The Record mode row tooltips open above their pill instead of over the row's label.
1 parent c53b173 commit 28708c3

8 files changed

Lines changed: 162 additions & 8 deletions

File tree

‎src/components/ai-edition/LeftPanel.tooltips.test.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,18 @@ describe("ChatStripPanel tooltips", () => {
196196
expect(tip.chip).toBe("Enter");
197197
});
198198

199+
// While the title is being edited the button is greyed, but it stays hoverable and does nothing.
200+
it("keeps Rename tooltip-able while the title is being edited", async () => {
201+
sessions.list = oneSession;
202+
renderPanel();
203+
const rename = await screen.findByRole("button", { name: "chat.renameConversation" });
204+
fireEvent.click(rename);
205+
await screen.findByDisplayValue("Conversation 1");
206+
expect(rename).toHaveAttribute("aria-disabled", "true");
207+
expect(rename).not.toBeDisabled();
208+
expect((await tooltipOn(rename)).text).toBe("chat.renameConversation");
209+
});
210+
199211
it("keeps Compact tooltip-able while there is no conversation, and does nothing", async () => {
200212
renderPanel();
201213
const compact = await screen.findByRole("button", { name: "chat.compactContext" });

‎src/components/ai-edition/LeftPanel.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1169,8 +1169,9 @@ export function ChatStripPanel() {
11691169
<button
11701170
type="button"
11711171
aria-label={t("chat.renameConversation")}
1172-
disabled={editingSessionId === activeSessionId}
1172+
aria-disabled={editingSessionId === activeSessionId || undefined}
11731173
onClick={() => {
1174+
if (editingSessionId === activeSessionId) return;
11741175
const current = sessions.find((s) => s.id === activeSessionId);
11751176
if (current) beginEditTitle(activeSessionId, current.title);
11761177
}}

‎src/components/ai-edition/v4/EditorShellV4.module.css‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,6 +1351,11 @@
13511351
background: var(--surface-3);
13521352
color: var(--fg);
13531353
}
1354+
/* Busy (Auto-enhance while a pass runs): still hoverable, so its tooltip opens. */
1355+
.tlToolBtn[aria-disabled="true"] {
1356+
opacity: 0.55;
1357+
cursor: not-allowed;
1358+
}
13541359
/* Toggle tools (Auto-Focus) read their lit state off aria-pressed, so the styling can't
13551360
drift from what assistive tech announces. */
13561361
.tlToolBtn[aria-pressed="true"] {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// @vitest-environment jsdom
2+
// The Regenerate button is a bare refresh icon beside a language list. While a transcription runs
3+
// it is greyed, but it stays focusable and hoverable so its tooltip still opens: a natively
4+
// disabled button takes neither, which is what `aria-disabled` is for.
5+
6+
import "@testing-library/jest-dom";
7+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
8+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9+
10+
const transcription = vi.hoisted(() => ({
11+
status: "idle" as "idle" | "running",
12+
request: vi.fn(),
13+
}));
14+
15+
vi.mock("@/lib/ai-edition/store/transcriptionStore", async (importOriginal) => {
16+
const actual = await importOriginal<typeof import("@/lib/ai-edition/store/transcriptionStore")>();
17+
return {
18+
...actual,
19+
useAssetTranscriptions: () => ({
20+
"asset-1": { assetId: "asset-1", status: transcription.status },
21+
}),
22+
useTranscriptionStore: (select: (state: { request: unknown }) => unknown) =>
23+
select({ request: transcription.request }),
24+
};
25+
});
26+
27+
// An echoing translator: the assertions read against keys, not prose that drifts with copy edits.
28+
vi.mock("@/contexts/I18nContext", () => ({
29+
useI18n: () => ({ locale: "en", setLocale: vi.fn() }),
30+
useScopedT: () => (key: string) => key,
31+
}));
32+
33+
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
34+
35+
import { TooltipProvider } from "@/components/ui/tooltip";
36+
import { assetSchema, createEmptyDocument } from "@/lib/ai-edition/schema";
37+
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
38+
import { MediaStage } from "./MediaStage";
39+
40+
class StubResizeObserver {
41+
observe = vi.fn();
42+
unobserve = vi.fn();
43+
disconnect = vi.fn();
44+
}
45+
46+
beforeEach(() => {
47+
vi.stubGlobal("ResizeObserver", StubResizeObserver);
48+
transcription.status = "idle";
49+
transcription.request.mockClear();
50+
const document = createEmptyDocument({ projectId: "p", title: "t" });
51+
useProjectStore.setState({
52+
projectId: "p",
53+
document: {
54+
...document,
55+
assets: [assetSchema.parse({ id: "asset-1", label: "take", originalPath: "/rec/take.mp4" })],
56+
},
57+
});
58+
});
59+
60+
afterEach(() => {
61+
cleanup();
62+
vi.unstubAllGlobals();
63+
useProjectStore.setState({ document: null });
64+
});
65+
66+
async function openDetail() {
67+
render(
68+
<TooltipProvider>
69+
<MediaStage onAddToTimeline={vi.fn(async () => undefined)} />
70+
</TooltipProvider>,
71+
);
72+
fireEvent.click(await screen.findByRole("button", { name: /take/ }));
73+
return screen.findByRole("button", { name: "mediaStage.regenerate" });
74+
}
75+
76+
describe("MediaStage Regenerate button", () => {
77+
it("asks for a new transcription, and says what it redoes in its tooltip", async () => {
78+
const regenerate = await openDetail();
79+
expect(regenerate).not.toHaveAttribute("title");
80+
act(() => regenerate.focus());
81+
expect((await screen.findByRole("tooltip")).textContent).toBe("mediaStage.regenerateTip");
82+
fireEvent.click(regenerate);
83+
expect(transcription.request).toHaveBeenCalledWith("asset-1", "auto");
84+
});
85+
86+
it("stays focusable and keeps its tooltip while a transcription runs, and fires nothing", async () => {
87+
transcription.status = "running";
88+
const regenerate = await openDetail();
89+
90+
// Greyed and announced as disabled, but not natively disabled.
91+
expect(regenerate).toHaveAttribute("aria-disabled", "true");
92+
expect(regenerate).not.toBeDisabled();
93+
expect(regenerate).toHaveStyle({ opacity: "0.6", cursor: "not-allowed" });
94+
expect(regenerate.querySelector("svg")).toHaveClass("animate-spin");
95+
96+
act(() => regenerate.focus());
97+
expect(regenerate).toHaveFocus();
98+
expect((await screen.findByRole("tooltip")).textContent).toBe("mediaStage.regenerateTip");
99+
100+
fireEvent.click(regenerate);
101+
expect(transcription.request).not.toHaveBeenCalled();
102+
});
103+
});

‎src/components/ai-edition/v4/MediaStage.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,8 +426,14 @@ export function MediaStage({
426426
<button
427427
type="button"
428428
aria-label={t("mediaStage.regenerate")}
429-
disabled={selectedBusy}
430-
onClick={() => void requestTranscription(selected.id, lang)}
429+
// `aria-disabled`, not `disabled`: a natively disabled button cannot take focus or
430+
// open its tooltip. It does nothing while a transcription runs.
431+
aria-disabled={selectedBusy || undefined}
432+
onClick={
433+
selectedBusy
434+
? undefined
435+
: () => void requestTranscription(selected.id, lang)
436+
}
431437
style={{
432438
width: 36,
433439
height: 36,

‎src/components/ai-edition/v4/RecStage.test.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,26 @@ describe("RecStage names and tooltips", () => {
607607
expect(await tooltipOn(pill("rec.editableCursor"))).toBe("rec.editableCursorTip");
608608
});
609609

610+
// To the left a tooltip sits on the label the user is reading, and below the last row it covers
611+
// Start recording: it opens above the pill.
612+
it("opens the row tooltips above their pill, never over the row's own label", async () => {
613+
stubRecordingPrefs({ cursorCaptureMode: "editable-overlay" });
614+
// Windows offers the desktop icons row too, so all three tooltips can be checked.
615+
Object.assign(window.electronAPI as object, { getPlatform: () => "win32" });
616+
renderRecStage();
617+
await screen.findByText("rec.hideDesktopIcons");
618+
for (const row of ["rec.systemAudio", "rec.editableCursor", "rec.hideDesktopIcons"]) {
619+
const control = pill(row);
620+
act(() => control.focus());
621+
await screen.findByRole("tooltip");
622+
expect(
623+
document.querySelector('[data-slot="tooltip-content"]')?.getAttribute("data-side"),
624+
).toBe("top");
625+
act(() => control.blur());
626+
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
627+
}
628+
});
629+
610630
// Decided: the rest are labelled and say what they do, so a tooltip would repeat the label.
611631
it("gives the microphone, camera and auto-zoom pills no tooltip", async () => {
612632
await renderReady();

‎src/components/ai-edition/v4/RecStage.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,10 @@ export function RecStage({
357357
{t("rec.systemAudio")}
358358
</div>
359359
{/* "System audio" is jargon for what the computer plays: the one row whose label
360-
alone does not say what it records. */}
361-
<Tooltip content={t("rec.systemAudioTip")} side="left">
360+
alone does not say what it records. The row tooltips open above their pill: to
361+
the left they would sit on the label being read, and below the last row they
362+
would cover Start recording. */}
363+
<Tooltip content={t("rec.systemAudioTip")} side="top">
362364
<button
363365
type="button"
364366
className={`${styles.recToggleBtn}${prefs.systemAudioEnabled ? ` ${styles.on}` : ""}`}
@@ -485,7 +487,7 @@ export function RecStage({
485487
<MousePointer2 size={15} />
486488
{t("rec.editableCursor")}
487489
</div>
488-
<Tooltip content={t("rec.editableCursorTip")} side="left">
490+
<Tooltip content={t("rec.editableCursorTip")} side="top">
489491
<button
490492
type="button"
491493
className={`${styles.recToggleBtn}${editableCursor ? ` ${styles.on}` : ""}`}
@@ -530,7 +532,7 @@ export function RecStage({
530532
<LayoutGrid size={15} />
531533
{t("rec.hideDesktopIcons")}
532534
</div>
533-
<Tooltip content={desktopIconsHint} side="left">
535+
<Tooltip content={desktopIconsHint} side="top">
534536
<button
535537
type="button"
536538
className={`${styles.recToggleBtn}${prefs.hideDesktopIcons ? ` ${styles.on}` : ""}`}

‎src/components/ai-edition/v4/V4Timeline.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1828,7 +1828,12 @@ export function V4Timeline({
18281828
type="button"
18291829
className={styles.tlToolBtn}
18301830
aria-label={t("toolbar.autoEnhance")}
1831-
disabled={autoBusy}
1831+
// `aria-disabled`, not `disabled`, so the tooltip still opens while a pass runs; the
1832+
// click must not open the menu meanwhile (Radix skips a prevented click).
1833+
aria-disabled={autoBusy || undefined}
1834+
onClick={(e) => {
1835+
if (autoBusy) e.preventDefault();
1836+
}}
18321837
>
18331838
{autoBusy ? (
18341839
<Loader2 className="animate-spin" size={16} />

0 commit comments

Comments
 (0)