diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx index 3ac2dd605..d4595d544 100644 --- a/apps/desktop/src/features/score/ScoreViewer.test.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx @@ -16,7 +16,9 @@ vi.mock("../../i18n", () => ({ scoreViewerFailedTitle: "Could not display the score", scoreViewerRetry: "Retry", scoreViewerPrevPage: "Previous page", + scoreViewerPrevPageDisabled: "Already on the first page", scoreViewerNextPage: "Next page", + scoreViewerNextPageDisabled: "Already on the last page", scoreViewerPageIndicator: "Page {current} of {total}", scoreViewerZoomIn: "Zoom in", scoreViewerZoomOut: "Zoom out", @@ -120,8 +122,8 @@ 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(); + expect(screen.getByRole("button", { name: "Previous page" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Next page" })).toHaveAttribute("aria-disabled", "false"); }); it("shows the file name when provided", async () => { @@ -165,7 +167,7 @@ describe("ScoreViewer", () => { expect(screen.getByText("password protected")).toBeInTheDocument(); }); - it("navigates pages and clamps at both bounds", async () => { + it("navigates pages and exposes unavailable reasons to keyboard focus", async () => { const { doc } = createFakeDocument(3); mockLoadTaskOnce(Promise.resolve(doc)); @@ -174,14 +176,44 @@ 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).not.toHaveAttribute("title"); + + const previousReason = screen.getByRole("tooltip"); + expect(previousReason).toHaveTextContent("Already on the first page"); + expect(previousButton).toHaveAttribute("aria-describedby", previousReason.id); + previousButton.focus(); + expect(previousButton).toHaveFocus(); + expect(previousReason).toHaveClass("group-focus-within:opacity-100"); + + const eventSpy = vi.spyOn(Event.prototype, "preventDefault"); + + // clicking an aria-disabled button calls preventDefault and ignores the action + fireEvent.click(previousButton); + expect(eventSpy).toHaveBeenCalled(); + expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + eventSpy.mockClear(); 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).not.toHaveAttribute("title"); + + const nextReason = screen.getByRole("tooltip"); + expect(nextReason).toHaveTextContent("Already on the last page"); + expect(nextButton).toHaveAttribute("aria-describedby", nextReason.id); + nextButton.focus(); + expect(nextButton).toHaveFocus(); + expect(nextReason).toHaveClass("group-focus-within:opacity-100"); + + // clicking an aria-disabled button calls preventDefault and ignores the action + fireEvent.click(nextButton); + expect(eventSpy).toHaveBeenCalled(); + expect(screen.getByText("Page 3 of 3")).toBeInTheDocument(); + eventSpy.mockRestore(); 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..121817d3c 100644 --- a/apps/desktop/src/features/score/ScoreViewer.tsx +++ b/apps/desktop/src/features/score/ScoreViewer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist"; import { AlertCircle, @@ -47,6 +47,8 @@ const MAX_ZOOM = 4; */ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps) { const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const previousPageDisabledReasonId = useId(); + const nextPageDisabledReasonId = useId(); const [status, setStatus] = useState("LOADING"); const [errorMessage, setErrorMessage] = useState(null); const [pdfDocument, setPdfDocument] = useState(null); @@ -241,6 +243,10 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps const pageIndicator = t("scoreViewerPageIndicator") .replace("{current}", String(pageNumber)) .replace("{total}", String(pageCount)); + const previousPageUnavailable = pageNumber <= 1; + const nextPageUnavailable = pageNumber >= pageCount; + const unavailableReasonClassName = + "pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 w-max max-w-48 -translate-x-1/2 rounded-md border border-white/10 bg-slate-950 px-2 py-1 text-center text-xs text-slate-100 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"; return ( @@ -287,29 +293,65 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
- + + + {previousPageUnavailable && ( + + {t("scoreViewerPrevPageDisabled")} + + )} + {pageIndicator} - + + + {nextPageUnavailable && ( + + {t("scoreViewerNextPageDisabled")} + + )} +
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..b583c5ca6 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -68,7 +68,9 @@ "scoreViewerFailedTitle": "Could not display the score", "scoreViewerRetry": "Retry", "scoreViewerPrevPage": "Previous page", + "scoreViewerPrevPageDisabled": "Already on the first page", "scoreViewerNextPage": "Next page", + "scoreViewerNextPageDisabled": "Already on the last page", "scoreViewerPageIndicator": "Page {current} of {total}", "scoreViewerZoomIn": "Zoom in", "scoreViewerZoomOut": "Zoom out", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..377c6ead2 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -68,7 +68,9 @@ "scoreViewerFailedTitle": "악보를 표시할 수 없습니다", "scoreViewerRetry": "다시 시도", "scoreViewerPrevPage": "이전 페이지", + "scoreViewerPrevPageDisabled": "이미 첫 페이지입니다", "scoreViewerNextPage": "다음 페이지", + "scoreViewerNextPageDisabled": "이미 마지막 페이지입니다", "scoreViewerPageIndicator": "{total}페이지 중 {current}페이지", "scoreViewerZoomIn": "확대", "scoreViewerZoomOut": "축소",