diff --git a/.jules/palette.md b/.jules/palette.md index c05638899..ecba8a642 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,3 +1,7 @@ ## 2024-05-19 - Replace HTML disabled with aria-disabled="true" for Accessible Tooltips **Learning:** Native HTML `disabled` attributes completely hide elements from screen readers and block all pointer/hover events, preventing tooltips from functioning for disabled elements. **Action:** Replace `disabled` with `aria-disabled="true"`, enforce block click handlers via `e.preventDefault()`, and add a title tooltip directly to the element to maintain full tooltip accessibility and keyboard focus support for visually impaired and mouse users. +## 2026-09-24 - Accessible tooltips instead of title + +**Learning:** When adding tooltips to icon-only buttons, especially those using `aria-disabled={true}`, do not use the native HTML title attribute as it lacks keyboard/touch support. +**Action:** Wrap the element in the custom Tooltip components (@/components/ui/tooltip). diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..a454a3e58 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -225,7 +225,7 @@ describe("App", () => { for (const name of ["Import", "Export"]) { const navButton = within(primaryNav).getByRole("button", { name }); expect(navButton).toHaveAttribute("aria-disabled", "true"); - expect(navButton).toHaveAttribute("title", "Coming soon"); + expect(navButton).not.toHaveAttribute("title"); expect(navButton).not.toBeDisabled(); } fireEvent.click(within(primaryNav).getByRole("button", { name: "Import" })); @@ -236,7 +236,7 @@ describe("App", () => { for (const name of ["Import", "Export"]) { const navButton = within(compactNav).getByRole("button", { name: `${name} compact view` }); expect(navButton).toHaveAttribute("aria-disabled", "true"); - expect(navButton).toHaveAttribute("title", "Coming soon"); + expect(navButton).not.toHaveAttribute("title"); expect(navButton).not.toBeDisabled(); } fireEvent.click(within(compactNav).getByRole("button", { name: "Import compact view" })); @@ -1139,11 +1139,13 @@ describe("App", () => { fireEvent.change(input, { target: { value: "https://youtube.com/watch?v=abc123DEF45" } }); const clearButton = screen.getByRole("button", { name: /Clear YouTube URL/i }); - clearButton.focus(); + act(() => clearButton.focus()); fireEvent.click(clearButton); expect(input).toHaveValue(""); - expect(document.activeElement).toBe(input); + await waitFor(() => { + 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); expect(input).not.toHaveAttribute("aria-invalid"); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..720966791 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -51,6 +51,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Toaster } from "@/components/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; const ANALYSIS_POLL_INTERVAL_MS = 250; const MAX_ERROR_DETAIL_LENGTH = 220; @@ -566,24 +567,25 @@ export function App() { const { icon: Icon, view } = item; return ( - + + handleNavSelect(view) : blockInactiveNavActivation} + className={`flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${ + active + ? "bg-blue-600/70 text-white shadow-[0_12px_30px_rgba(37,99,235,0.32)]" + : enabled + ? "text-slate-200 hover:bg-white/5" + : "cursor-not-allowed text-slate-500 opacity-70" + }`} + > + + {title ? {title} : null} + ); })} @@ -611,26 +613,30 @@ export function App() {
- - + + + + {t("settingsComingSoon")} + + + + + {t("helpComingSoon")} +
@@ -642,25 +648,26 @@ export function App() { const { icon: Icon, view } = item; return ( - + + handleNavSelect(view) : blockInactiveNavActivation} + className={`inline-flex min-h-10 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${ + active + ? "bg-blue-600/70 text-white" + : enabled + ? "text-slate-200 hover:bg-white/5" + : "cursor-not-allowed text-slate-500 opacity-70" + }`} + > + + {title ? {title} : null} + ); })} @@ -709,15 +716,17 @@ export function App() { aria-describedby={selectionError && selectionErrorSource === "youtube" ? "selection-error" : undefined} /> {youtubeUrl && !analysisInFlight && !isStarting && !isImporting ? ( - + + + + {t("clearYoutubeUrl")} + ) : null} diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index 1d8224c5a..6a0853944 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -1275,9 +1275,7 @@ def test_workflow_concurrency_cancels_only_superseded_pr_heads() -> None: workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8") assert "concurrency:" in workflow, workflow_name assert "cancel-in-progress: false" in workflow, workflow_name - assert "contents: read" in workflow or "permissions: read-all" in workflow, ( - workflow_name - ) + assert "contents: read" in workflow or "permissions: read-all" in workflow, workflow_name assert "pull_request:" not in (workflows_dir / "release.yml").read_text(encoding="utf-8")