Skip to content
Open
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
4 changes: 4 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 23 additions & 0 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);

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."));

Expand Down
41 changes: 32 additions & 9 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Users,
Wand2,
Loader2,
X,
} from "lucide-react";
import {
SUPPORTED_AUDIO_FORMATS,
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -228,6 +230,7 @@ export function App() {
const [youtubeUrl, setYoutubeUrl] = useState("");
const [isImporting, setIsImporting] = useState(false);
const activeJobIdRef = useRef<string | null>(null);
const youtubeInputRef = useRef<HTMLInputElement | null>(null);

const analysisInFlight = jobStatus?.state === "queued" || jobStatus?.state === "running";
const selectedRequest: AnalysisJobRequest = selectedBootstrap
Expand Down Expand Up @@ -419,6 +422,12 @@ export function App() {
}
};

/** Documented. */
const handleClearYoutubeUrl = () => {
youtubeInputRef.current?.focus();
setYoutubeUrl("");
};
Comment thread
seonghobae marked this conversation as resolved.

/** Documented. */
const handleLoadProject = async () => {
try {
Expand Down Expand Up @@ -601,15 +610,29 @@ export function App() {
<div className="grid min-w-0 gap-2 rounded-2xl border border-white/10 bg-white/[0.04] p-1.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<Music2 className="ml-2 size-4 shrink-0 text-rose-300" aria-hidden="true" />
<Input
type="text"
placeholder={t("youtubePlaceholder")}
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
disabled={analysisInFlight || isStarting || isImporting}
className="h-10 flex-1 border-0 bg-transparent text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300"
aria-label="YouTube URL"
/>
<div className="relative min-w-0 flex-1">
<Input
ref={youtubeInputRef}
type="text"
placeholder={t("youtubePlaceholder")}
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
disabled={analysisInFlight || isStarting || isImporting}
className="h-10 w-full border-0 bg-transparent pr-9 text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300"
aria-label="YouTube URL"
/>
{youtubeUrl && !analysisInFlight && !isStarting && !isImporting ? (
<button
type="button"
onClick={handleClearYoutubeUrl}
className="absolute right-1 top-1/2 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-md text-slate-400 transition hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
aria-label={CLEAR_YOUTUBE_URL_LABEL}
title={CLEAR_YOUTUBE_URL_LABEL}
>
<X className="size-4" aria-hidden="true" />
</button>
) : null}
</div>
</div>
<Button
onClick={handleImportYoutube}
Expand Down