diff --git a/.Jules/palette.md b/.Jules/palette.md index b6f5d5bf..8eaf4a5f 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -29,3 +29,7 @@ ## 2024-07-01 - Testing components with focusable disabled button wrappers **Learning:** When native disabled buttons are wrapped in a focusable `span` to provide accessible tooltips, tests that previously found and clicked the `button` (by temporarily removing the `disabled` attribute) may fail or become overly complex. It is cleaner and more accurate to query the wrapper element (e.g. via its `title`) and fire events on it, reflecting the actual accessible DOM structure. **Action:** When testing UI components that wrap disabled buttons in a focusable span for accessibility (e.g., using a tooltip/title), use `screen.getByTitle(...)` to query the wrapper element for interactions like `fireEvent.click` rather than `screen.getByRole('button')`. + +## 2026-07-02 - Inline clear buttons preserve focus +**Learning:** Inline clear buttons often unmount immediately after clearing state, which can drop keyboard focus to the document body. +**Action:** Move focus back to the owning input before clearing state, and cover the behavior with a DOM focus test. diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index c039dfba..2f8290d1 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1030,6 +1030,29 @@ describe("App", () => { }); }); + it("clears the YouTube URL without clearing local selection errors and returns focus to the input", async () => { + tauriInvoke.mockRejectedValueOnce(new Error("Choose a WAV, MP3, FLAC, or M4A file to start analysis.")); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent(/choose a wav, mp3, flac, or m4a file/i); + }); + + const input = screen.getByRole("textbox", { name: /YouTube URL/i }); + fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); + + const clearButton = screen.getByRole("button", { name: /Clear YouTube URL/i }); + clearButton.focus(); + fireEvent.click(clearButton); + + expect(input).toHaveValue(""); + expect(document.activeElement).toBe(input); + expect(screen.queryByRole("button", { name: /Clear YouTube URL/i })).toBeNull(); + expect(screen.getByRole("alert")).toHaveTextContent(/choose a wav, mp3, flac, or m4a file/i); + }); + it("handles YouTube import failure with a message", async () => { tauriInvoke.mockRejectedValueOnce(new Error("This video is age restricted.")); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 24f5fb09..7ca291e6 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -21,6 +21,7 @@ import { Users, Wand2, Loader2, + X, } from "lucide-react"; import { SUPPORTED_AUDIO_FORMATS, @@ -52,6 +53,7 @@ const MAX_ERROR_DETAIL_LENGTH = 220; const LOCAL_PATH_PATTERN = /(?:[A-Za-z]:[\\/][^\s"'<>]+|\\\\[^\s"'<>]+|\/(?:Users|home|var|tmp|private|Volumes)\/[^\s"'<>]+)/g; const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi; const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi; +const CLEAR_YOUTUBE_URL_LABEL = "Clear YouTube URL"; const NAV_ITEMS = [ { label: "Workspace", icon: Home, active: true }, @@ -228,6 +230,7 @@ export function App() { const [youtubeUrl, setYoutubeUrl] = useState(""); const [isImporting, setIsImporting] = useState(false); const activeJobIdRef = useRef(null); + const youtubeInputRef = useRef(null); const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running"; const selectedRequest: AnalysisJobRequest = selectedBootstrap @@ -419,6 +422,12 @@ export function App() { } }; + /** Documented. */ + const handleClearYoutubeUrl = () => { + youtubeInputRef.current?.focus(); + setYoutubeUrl(""); + }; + /** Documented. */ const handleLoadProject = async () => { try { @@ -601,15 +610,29 @@ export function App() {