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
3 changes: 3 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@
## 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-06 - Accessible Disabled Buttons
**Learning:** Wrapping disabled `<button>` elements in focusable `<span role="button" tabIndex={0}>` wrappers to enable tooltips is an anti-pattern that violates nested interactive control rules and breaks screen-reader semantics.
**Action:** Always use a single native `<button aria-disabled="true">` combined with `onClick={(e) => e.preventDefault()}` and direct `title` attributes. This ensures semantic correctness, focusability, and tooltip functionality without breaking accessibility rules. Simulate the click events in tests to verify `event.defaultPrevented`.
28 changes: 21 additions & 7 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, createEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";

Expand Down Expand Up @@ -1405,8 +1405,10 @@ describe("App", () => {

it("does nothing when Save Project is clicked but there is no jobResult", () => {
render(<App />);
const saveSpan = screen.getByTitle("Analyze a song to enable saving");
fireEvent.click(saveSpan);
const saveButton = screen.getByTitle("Analyze a song to enable saving");
const event = createEvent.click(saveButton);
fireEvent(saveButton, event);
expect(event.defaultPrevented).toBe(true);
expect(mockSaveProject).not.toHaveBeenCalled();
});

Expand All @@ -1427,10 +1429,22 @@ describe("App", () => {
});


it("renders disabled Settings and Help buttons as focusable spans for accessibility", () => {
it("renders disabled Settings and Help buttons as focusable buttons for accessibility", () => {
render(<App />);
const settingsSpan = screen.getByTitle("Settings coming soon");
expect(settingsSpan).toHaveAttribute("tabIndex", "0");
expect(settingsSpan).toHaveAttribute("role", "button");
const settingsButton = screen.getByTitle("Settings coming soon");
expect(settingsButton.tagName).toBe("BUTTON");
expect(settingsButton).toHaveAttribute("aria-disabled", "true");

const event = createEvent.click(settingsButton);
fireEvent(settingsButton, event);
expect(event.defaultPrevented).toBe(true);

const helpButton = screen.getByTitle("Help coming soon");
expect(helpButton.tagName).toBe("BUTTON");
expect(helpButton).toHaveAttribute("aria-disabled", "true");

const helpEvent = createEvent.click(helpButton);
fireEvent(helpButton, helpEvent);
expect(helpEvent.defaultPrevented).toBe(true);
});
});
50 changes: 29 additions & 21 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -535,18 +535,26 @@ export function App() {
</div>

<div className="flex items-center justify-between text-slate-400">
<span tabIndex={0} role="button" aria-disabled="true" title="Settings coming soon" className="inline-block cursor-not-allowed rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300">
<button
type="button"
aria-disabled="true"
title="Settings coming soon"
onClick={(e) => e.preventDefault()}
className="cursor-not-allowed rounded-xl p-2 text-slate-600 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
>
<span className="sr-only">Settings coming soon</span>
<button type="button" disabled aria-hidden="true" className="pointer-events-none rounded-xl p-2 text-slate-600 transition">
<Settings className="size-5" aria-hidden="true" />
</button>
</span>
<span tabIndex={0} role="button" aria-disabled="true" title="Help coming soon" className="inline-block cursor-not-allowed rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300">
<Settings className="size-5" aria-hidden="true" />
</button>
<button
type="button"
aria-disabled="true"
title="Help coming soon"
onClick={(e) => e.preventDefault()}
className="cursor-not-allowed rounded-xl p-2 text-slate-600 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
>
<span className="sr-only">Help coming soon</span>
<button type="button" disabled aria-hidden="true" className="pointer-events-none rounded-xl p-2 text-slate-600 transition">
<CircleHelp className="size-5" aria-hidden="true" />
</button>
</span>
<CircleHelp className="size-5" aria-hidden="true" />
</button>
</div>
</div>
</aside>
Expand Down Expand Up @@ -646,17 +654,17 @@ export function App() {
Save Project
</Button>
) : (
<span tabIndex={0} role="button" aria-disabled="true" title="Analyze a song to enable saving" className="inline-block cursor-not-allowed rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300">
<Button
disabled
variant="outline"
className="min-h-11 border-white/10 bg-white/5 font-semibold text-slate-100"
aria-label="Save Project"
>
<Save className="mr-2 size-4" aria-hidden="true" />
Save Project
</Button>
</span>
<Button
aria-disabled="true"
onClick={(e) => e.preventDefault()}
title="Analyze a song to enable saving"
variant="outline"
className="min-h-11 cursor-not-allowed border-white/10 bg-white/5 font-semibold text-slate-100 opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
aria-label="Save Project"
>
<Save className="mr-2 size-4" aria-hidden="true" />
Save Project
</Button>
)}
<Button
onClick={handleStartAnalysis}
Expand Down