From 915db4721a09dc90d91966b774d7b503b39a9238 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:24:01 +0900 Subject: [PATCH 01/32] fix(a11y): keep unavailable score controls discoverable Replace native-disabled score-open, score-remove, and PDF pagination controls with guarded aria-disabled behavior, preserve keyboard focus and native tooltips, associate blocked score actions with the visible project requirement, and verify activation is prevented at both pagination boundaries. --- CHANGELOG.md | 6 +++- .../src/features/score/ScoreView.test.tsx | 24 ++++++++++--- apps/desktop/src/features/score/ScoreView.tsx | 36 ++++++++++++++----- .../src/features/score/ScoreViewer.test.tsx | 23 +++++++++--- .../src/features/score/ScoreViewer.tsx | 28 +++++++++++---- 5 files changed, 93 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..81a526d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Changed + +- Keep unavailable score-open, score-remove, and PDF pagination controls keyboard-focusable, expose their unavailable state to assistive technology, explain blocked actions, and prevent activation at the action boundary. + ## [0.1.3] - 2026-04-29 ### Fixed @@ -65,4 +69,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx index de4ccb95c..b44f3a104 100644 --- a/apps/desktop/src/features/score/ScoreView.test.tsx +++ b/apps/desktop/src/features/score/ScoreView.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types"; import { invoke } from "@tauri-apps/api/core"; @@ -96,11 +96,27 @@ 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: "Remove: opener.pdf" })).toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" })); + const openBtn = screen.getByRole("button", { name: "Open score: opener.pdf" }); + expect(openBtn).toHaveAttribute("aria-disabled", "true"); + expect(openBtn).toHaveAttribute("aria-describedby"); + expect(openBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); + expect(openBtn).toHaveAttribute("title", "scoreNavDisabledHint"); + + const removeBtn = screen.getByRole("button", { name: "Remove: opener.pdf" }); + expect(removeBtn).toHaveAttribute("aria-disabled", "true"); + expect(removeBtn).toHaveAttribute("aria-describedby"); + expect(removeBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); + expect(removeBtn).toHaveAttribute("title", "Remove: opener.pdf"); + + const openClickEvent = createEvent.click(openBtn); + fireEvent(openBtn, openClickEvent); + expect(openClickEvent.defaultPrevented).toBe(true); expect(mockInvoke).not.toHaveBeenCalled(); + + const clickEvent = createEvent.click(removeBtn); + fireEvent(removeBtn, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); }); it("attaches a score, persists the metadata, and opens the new PDF", async () => { diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx index 72732450f..e9d93b3de 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")}

)} @@ -183,11 +187,19 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) { > diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx index 3ac2dd605..bd4761639 100644 --- a/apps/desktop/src/features/score/ScoreViewer.test.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist"; import { ScoreViewer } from "./ScoreViewer"; @@ -120,8 +120,15 @@ describe("ScoreViewer", () => { expect(page.render).toHaveBeenCalled(); }); expect(page.getViewport).toHaveBeenCalledWith({ scale: 1 }); - expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled(); + const prevBtn = screen.getByRole("button", { name: "Previous page" }); + const nextBtn = screen.getByRole("button", { name: "Next page" }); + expect(prevBtn).toHaveAttribute("aria-disabled", "true"); + expect(prevBtn).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); + expect(nextBtn).toHaveAttribute("aria-disabled", "false"); + + const clickEvent = createEvent.click(prevBtn); + fireEvent(prevBtn, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); }); it("shows the file name when provided", async () => { @@ -174,14 +181,20 @@ describe("ScoreViewer", () => { expect(await screen.findByText("Page 1 of 3")).toBeInTheDocument(); const previousButton = screen.getByRole("button", { name: "Previous page" }); const nextButton = screen.getByRole("button", { name: "Next page" }); - expect(previousButton).toBeDisabled(); + expect(previousButton).toHaveAttribute("aria-disabled", "true"); + expect(previousButton).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); fireEvent.click(nextButton); expect(screen.getByText("Page 2 of 3")).toBeInTheDocument(); fireEvent.click(nextButton); expect(screen.getByText("Page 3 of 3")).toBeInTheDocument(); - expect(nextButton).toBeDisabled(); + expect(nextButton).toHaveAttribute("aria-disabled", "true"); + expect(nextButton).toHaveClass("aria-disabled:cursor-not-allowed", "aria-disabled:opacity-60"); + + const clickEvent = createEvent.click(nextButton); + fireEvent(nextButton, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); await waitFor(() => { expect(doc.getPage).toHaveBeenCalledWith(3); diff --git a/apps/desktop/src/features/score/ScoreViewer.tsx b/apps/desktop/src/features/score/ScoreViewer.tsx index 82692469e..722dcfeb1 100644 --- a/apps/desktop/src/features/score/ScoreViewer.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.tsx @@ -258,6 +258,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps size="icon-lg" className="size-12" aria-label={t("scoreViewerZoomOut")} + title={t("scoreViewerZoomOut")} onClick={zoomOut} >