diff --git a/apps/desktop/src/features/score/ScoreView.accessibility.test.tsx b/apps/desktop/src/features/score/ScoreView.accessibility.test.tsx new file mode 100644 index 00000000..b960456c --- /dev/null +++ b/apps/desktop/src/features/score/ScoreView.accessibility.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { ScoreView } from "./ScoreView"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn() +})); + +vi.mock("./ScoreViewer", () => ({ + ScoreViewer: () =>
+})); + +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => + ({ + scoreViewTitle: "Score", + scoreViewSubtitle: "Attach validated PDF scores to the current song.", + scoreListTitle: "Attached scores", + scoreAttach: "Add score", + scoreRemove: "Remove", + scoreOpen: "Open score", + scoreRequiresProject: "Scores attach to the active analysis project.", + scoreNavDisabledHint: "Analyze or open a song first" + })[key] ?? key, + detectPreferredLocale: () => "en" +})); + +const song = { + id: "song-a11y", + title: "Accessible Set", + sections: [], + exportSummary: { format: "cue-sheet", headline: "", focusSections: [] }, + scoreAttachments: [ + { + id: "score-a11y", + fileName: "opener.pdf" + } + ] +} as RehearsalSong; + +describe("ScoreView disabled-score accessibility", () => { + it("references the visible disabled reason from each unavailable score button", () => { + render(); + + const reason = screen.getByText("Scores attach to the active analysis project."); + const openButton = screen.getByRole("button", { name: "Open score: opener.pdf" }); + const descriptionId = openButton.getAttribute("aria-describedby"); + + expect(openButton).toHaveAttribute("aria-disabled", "true"); + expect(openButton).toHaveAttribute("title", "Analyze or open a song first"); + expect(descriptionId).toBe(reason.id); + expect(descriptionId).not.toBe(""); + expect(document.getElementById(descriptionId ?? "")).toBe(reason); + }); + + it("removes disabled-only semantics when a project workspace is available", () => { + render(); + + const openButton = screen.getByRole("button", { name: "Open score: opener.pdf" }); + + expect(openButton).not.toHaveAttribute("aria-disabled"); + expect(openButton).not.toHaveAttribute("aria-describedby"); + expect(openButton).not.toHaveAttribute("title"); + expect( + screen.queryByText("Scores attach to the active analysis project.") + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx index de4ccb95..4c432e7f 100644 --- a/apps/desktop/src/features/score/ScoreView.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -96,7 +96,7 @@ describe("ScoreView", () => { expect(screen.getByText("Scores attach to the active analysis project.")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Add score" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Open score: opener.pdf" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Open score: opener.pdf" })).toHaveAttribute("aria-disabled", "true"); expect(screen.getByRole("button", { name: "Remove: opener.pdf" })).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); @@ -180,6 +180,16 @@ describe("ScoreView", () => { }); }); + it("renders disabled score open buttons with aria-disabled and a tooltip when there is no projectId", () => { + const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); + render(); + + const button = screen.getByRole("button", { name: "Open score: opener.pdf" }); + expect(button).toHaveAttribute("aria-disabled", "true"); + expect(button).toHaveAttribute("title", "scoreNavDisabledHint"); + expect(button).not.toBeDisabled(); + }); + it("accepts Uint8Array read responses from the bridge", async () => { mockInvoke.mockResolvedValueOnce(new Uint8Array([7, 7])); const song = makeSong([{ id: SCORE_ID, fileName: "opener.pdf" }]); diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index 72732450..a5605afb 100644 --- a/apps/desktop/src/features/score/ScoreView.tsx +++ b/apps/desktop/src/features/score/ScoreView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useId, useMemo, useRef, useState } from "react"; import { FileMusic, FilePlus2, Loader2, Trash2 } from "lucide-react"; import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; import { createTranslator, detectPreferredLocale } from "../../i18n"; @@ -39,6 +39,7 @@ function bridgeErrorDetail(error: unknown, fallback: string): string { */ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const scoreRequiresProjectId = useId(); const attachments = useMemo(() => song.scoreAttachments ?? [], [song.scoreAttachments]); const [selected, setSelected] = useState(null); const [pdfBytes, setPdfBytes] = useState(null); @@ -149,7 +150,10 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
{!projectId && ( -

+

{t("scoreRequiresProject")}

)} @@ -184,10 +188,12 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {