Skip to content
Closed
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
@@ -0,0 +1,4 @@

## 2024-07-11 - Accessible Disabled Buttons and Tooltips
**Learning:** Wrapping disabled `<button>` elements inside a `span role="button"` just to attach a `title` tooltip breaks valid HTML semantics, keyboard accessibility, and standard screen reader behavior, as it creates invalid nested interactive elements. Test queries looking for `getByRole('button')` can easily fail if there are two conceptual buttons nested together.
**Action:** When a button needs to be visually disabled but still require a tooltip on hover/focus, use a native `<button>` element, remove the HTML `disabled` attribute, set `aria-disabled="true"`, block clicks using `onClick={(e) => e.preventDefault()}`, and apply `title` directly to the button. Ensure matching Tailwind variants (`aria-disabled:opacity-50`) are added to handle styling.
11 changes: 10 additions & 1 deletion 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, within } from "@testing-library/react";
import { act, fireEvent, createEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import { MAX_YOUTUBE_URL_LENGTH } from "./lib/analysis";
Expand Down Expand Up @@ -1514,4 +1514,13 @@ describe("App", () => {
expect(helpButton).toHaveAttribute("aria-disabled", "true");
expect(helpButton).not.toHaveAttribute("disabled");
});

it("prevents default click on disabled Save Project button", () => {
render(<App />);
const saveButton = screen.getByRole("button", { name: /Save project/i });
expect(saveButton).toHaveAttribute("aria-disabled", "true");
const event = createEvent.click(saveButton);
fireEvent(saveButton, event);
expect(event.defaultPrevented).toBe(true);
});
});
22 changes: 11 additions & 11 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -710,17 +710,17 @@ export function App() {
{t("saveProject")}
</Button>
) : (
<span tabIndex={0} role="button" aria-disabled="true" title={t("saveRequiresAnalysis")} 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={t("saveProject")}
>
<Save className="mr-2 size-4" aria-hidden="true" />
{t("saveProject")}
</Button>
</span>
<Button
aria-disabled={true}
onClick={(e) => e.preventDefault()}
title={t("saveRequiresAnalysis")}
variant="outline"
className="min-h-11 border-white/10 bg-white/5 font-semibold text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
aria-label={t("saveProject")}
>
<Save className="mr-2 size-4" aria-hidden="true" />
{t("saveProject")}
</Button>
)}
<Button
onClick={handleStartAnalysis}
Expand Down
14 changes: 7 additions & 7 deletions apps/desktop/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80 disabled:hover:bg-primary",
default: "bg-primary text-primary-foreground hover:bg-primary/80 disabled:hover:bg-primary aria-disabled:hover:bg-primary",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground disabled:hover:bg-background disabled:hover:text-inherit dark:border-input dark:bg-input/30 dark:hover:bg-input/50 dark:disabled:hover:bg-input/30",
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground disabled:hover:bg-background disabled:hover:text-inherit aria-disabled:hover:bg-background aria-disabled:hover:text-inherit dark:border-input dark:bg-input/30 dark:hover:bg-input/50 dark:disabled:hover:bg-input/30 dark:aria-disabled:hover:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground disabled:hover:bg-secondary disabled:hover:text-secondary-foreground",
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground disabled:hover:bg-secondary disabled:hover:text-secondary-foreground aria-disabled:hover:bg-secondary aria-disabled:hover:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground disabled:hover:bg-transparent disabled:hover:text-inherit dark:hover:bg-muted/50 dark:disabled:hover:bg-transparent",
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground disabled:hover:bg-transparent disabled:hover:text-inherit aria-disabled:hover:bg-transparent aria-disabled:hover:text-inherit dark:hover:bg-muted/50 dark:disabled:hover:bg-transparent dark:aria-disabled:hover:bg-transparent",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 disabled:hover:bg-destructive/10 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40 dark:disabled:hover:bg-destructive/20",
link: "text-primary underline-offset-4 hover:underline disabled:hover:no-underline",
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 disabled:hover:bg-destructive/10 aria-disabled:hover:bg-destructive/10 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40 dark:disabled:hover:bg-destructive/20 dark:aria-disabled:hover:bg-destructive/20",
link: "text-primary underline-offset-4 hover:underline disabled:hover:no-underline aria-disabled:hover:no-underline",
},
size: {
default:
Expand Down
Loading