Skip to content
42 changes: 37 additions & 5 deletions apps/desktop/src/features/score/ScoreViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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));

Expand All @@ -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);
Expand Down
84 changes: 63 additions & 21 deletions apps/desktop/src/features/score/ScoreViewer.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<ScoreViewerStatus>("LOADING");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy | null>(null);
Expand Down Expand Up @@ -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 (
<Card className="border-cyan-300/20 bg-slate-950/75 backdrop-blur-xl">
Expand Down Expand Up @@ -287,29 +293,65 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
<canvas ref={canvasRef} className="mx-auto block max-w-none" />
</div>
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="icon-lg"
className="size-14"
aria-label={t("scoreViewerPrevPage")}
disabled={pageNumber <= 1}
onClick={goToPreviousPage}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</Button>
<span className="group relative inline-flex">
<Button
variant="outline"
size="icon-lg"
className="size-14"
aria-label={t("scoreViewerPrevPage")}
aria-disabled={previousPageUnavailable}
aria-describedby={previousPageUnavailable ? previousPageDisabledReasonId : undefined}
onClick={(e) => {
if (previousPageUnavailable) {
e.preventDefault();
return;
}
goToPreviousPage();
}}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</Button>
{previousPageUnavailable && (
<span
id={previousPageDisabledReasonId}
role="tooltip"
className={unavailableReasonClassName}
>
{t("scoreViewerPrevPageDisabled")}
</span>
)}
</span>
<span className="min-w-28 text-center text-sm font-semibold text-slate-200">
{pageIndicator}
</span>
<Button
variant="outline"
size="icon-lg"
className="size-14"
aria-label={t("scoreViewerNextPage")}
disabled={pageNumber >= pageCount}
onClick={goToNextPage}
>
<ChevronRight className="size-6" aria-hidden="true" />
</Button>
<span className="group relative inline-flex">
<Button
variant="outline"
size="icon-lg"
className="size-14"
aria-label={t("scoreViewerNextPage")}
aria-disabled={nextPageUnavailable}
aria-describedby={nextPageUnavailable ? nextPageDisabledReasonId : undefined}
onClick={(e) => {
if (nextPageUnavailable) {
e.preventDefault();
return;
}
goToNextPage();
}}
>
<ChevronRight className="size-6" aria-hidden="true" />
</Button>
{nextPageUnavailable && (
<span
id={nextPageDisabledReasonId}
role="tooltip"
className={unavailableReasonClassName}
>
{t("scoreViewerNextPageDisabled")}
</span>
)}
</span>
</div>
</CardContent>
</Card>
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
seonghobae marked this conversation as resolved.
"scoreViewerPageIndicator": "Page {current} of {total}",
"scoreViewerZoomIn": "Zoom in",
"scoreViewerZoomOut": "Zoom out",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@
"scoreViewerFailedTitle": "악보λ₯Ό ν‘œμ‹œν•  수 μ—†μŠ΅λ‹ˆλ‹€",
"scoreViewerRetry": "λ‹€μ‹œ μ‹œλ„",
"scoreViewerPrevPage": "이전 νŽ˜μ΄μ§€",
"scoreViewerPrevPageDisabled": "이미 첫 νŽ˜μ΄μ§€μž…λ‹ˆλ‹€",
"scoreViewerNextPage": "λ‹€μŒ νŽ˜μ΄μ§€",
"scoreViewerNextPageDisabled": "이미 λ§ˆμ§€λ§‰ νŽ˜μ΄μ§€μž…λ‹ˆλ‹€",
"scoreViewerPageIndicator": "{total}νŽ˜μ΄μ§€ 쀑 {current}νŽ˜μ΄μ§€",
"scoreViewerZoomIn": "ν™•λŒ€",
"scoreViewerZoomOut": "μΆ•μ†Œ",
Expand Down
Loading