diff --git a/.Jules/palette.md b/.Jules/palette.md
index 5c1c1698..b7cf7fc2 100644
--- a/.Jules/palette.md
+++ b/.Jules/palette.md
@@ -37,3 +37,6 @@
## 2026-07-02 - Inline clear buttons preserve focus
**Learning:** Inline clear buttons often unmount immediately after clearing state, which can drop keyboard focus to the document body.
**Action:** Move focus back to the owning input before clearing state, and cover the behavior with a DOM focus test.
+## 2026-07-03 - ScoreViewer 아이콘 버튼의 aria-disabled 및 title 툴팁 처리
+**Learning:** 악보 뷰어 등에서 페이지 이동이나 확대/축소와 같이 한계치에 도달했을 때 비활성화되어야 하는 아이콘 전용 버튼들은 시각적인 맥락 부족을 보완하기 위해 native `disabled` 대신 `aria-disabled="true"`를 사용하고, 호버 시 명확한 툴팁(`title`)을 제공해야 마우스 및 키보드(스크린 리더) 사용자 모두에게 접근성이 높습니다. 또한 click 이벤트 핸들러 내부에서 상태 검사를 통해 로직 실행을 차단해주어야 합니다.
+**Action:** 아이콘 버튼의 기능이 한계에 도달해 비활성화 처리할 때는 `aria-disabled` 속성을 사용하여 포커스 이동을 허용하고 접근성을 높이면서, 이벤트 핸들러 안에 조기 리턴 로직을 추가하여 동작을 막으세요.
diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx
index 3ac2dd60..e5ab7201 100644
--- a/apps/desktop/src/features/score/ScoreViewer.test.tsx
+++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx
@@ -120,8 +120,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" })).not.toHaveAttribute("aria-disabled", "true");
});
it("shows the file name when provided", async () => {
@@ -174,14 +174,14 @@ 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");
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");
await waitFor(() => {
expect(doc.getPage).toHaveBeenCalledWith(3);
@@ -192,6 +192,17 @@ describe("ScoreViewer", () => {
await waitFor(() => {
expect(doc.getPage).toHaveBeenCalledWith(2);
});
+
+ fireEvent.click(previousButton);
+ expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
+
+ // Verify clamped logic
+ fireEvent.click(previousButton);
+ expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
+ fireEvent.click(nextButton);
+ fireEvent.click(nextButton);
+ fireEvent.click(nextButton);
+ expect(screen.getByText("Page 3 of 3")).toBeInTheDocument();
});
it("zooms in and out with clamping and returns to fit-width", async () => {
@@ -226,6 +237,10 @@ describe("ScoreViewer", () => {
expect(page.getViewport).toHaveBeenCalledWith({ scale: 0.5 });
});
+ // Test clamped zooming
+ fireEvent.click(zoomOutButton);
+ expect(page.getViewport).toHaveBeenCalledWith({ scale: 0.5 });
+
fireEvent.click(fitWidthButton);
expect(fitWidthButton).toHaveAttribute("aria-pressed", "true");
});
diff --git a/apps/desktop/src/features/score/ScoreViewer.tsx b/apps/desktop/src/features/score/ScoreViewer.tsx
index 82692469..767a7e00 100644
--- a/apps/desktop/src/features/score/ScoreViewer.tsx
+++ b/apps/desktop/src/features/score/ScoreViewer.tsx
@@ -152,23 +152,39 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
}, [status, pdfDocument, pageNumber, zoom, fitWidth, containerWidth]);
/** Move to the previous page, clamped at the first page. */
- const goToPreviousPage = () => {
+ const goToPreviousPage = (e?: { preventDefault: () => void }) => {
+ if (pageNumber <= 1) {
+ e?.preventDefault();
+ return;
+ }
setPageNumber((current) => Math.max(1, current - 1));
};
/** Move to the next page, clamped at the last page. */
- const goToNextPage = () => {
+ const goToNextPage = (e?: { preventDefault: () => void }) => {
+ if (pageNumber >= pageCount) {
+ e?.preventDefault();
+ return;
+ }
setPageNumber((current) => Math.min(pageCount, current + 1));
};
/** Switch to manual zoom and enlarge, clamped at the maximum scale. */
- const zoomIn = () => {
+ const zoomIn = (e?: { preventDefault: () => void }) => {
+ if (!fitWidth && zoom >= MAX_ZOOM) {
+ e?.preventDefault();
+ return;
+ }
setFitWidth(false);
setZoom((current) => Math.min(MAX_ZOOM, current * ZOOM_STEP));
};
/** Switch to manual zoom and shrink, clamped at the minimum scale. */
- const zoomOut = () => {
+ const zoomOut = (e?: { preventDefault: () => void }) => {
+ if (!fitWidth && zoom <= MIN_ZOOM) {
+ e?.preventDefault();
+ return;
+ }
setFitWidth(false);
setZoom((current) => Math.max(MIN_ZOOM, current / ZOOM_STEP));
};
@@ -258,6 +274,8 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
size="icon-lg"
className="size-12"
aria-label={t("scoreViewerZoomOut")}
+ title={t("scoreViewerZoomOut")}
+ aria-disabled={!fitWidth && zoom <= MIN_ZOOM ? true : undefined}
onClick={zoomOut}
>
@@ -267,6 +285,8 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
size="icon-lg"
className="size-12"
aria-label={t("scoreViewerZoomIn")}
+ title={t("scoreViewerZoomIn")}
+ aria-disabled={!fitWidth && zoom >= MAX_ZOOM ? true : undefined}
onClick={zoomIn}
>
@@ -275,6 +295,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
variant={fitWidth ? "secondary" : "outline"}
className="h-12 px-4 text-base"
aria-label={t("scoreViewerFitWidth")}
+ title={t("scoreViewerFitWidth")}
aria-pressed={fitWidth}
onClick={fitToWidth}
>
@@ -292,7 +313,8 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
size="icon-lg"
className="size-14"
aria-label={t("scoreViewerPrevPage")}
- disabled={pageNumber <= 1}
+ title={t("scoreViewerPrevPage")}
+ aria-disabled={pageNumber <= 1 ? true : undefined}
onClick={goToPreviousPage}
>
@@ -305,7 +327,8 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
size="icon-lg"
className="size-14"
aria-label={t("scoreViewerNextPage")}
- disabled={pageNumber >= pageCount}
+ title={t("scoreViewerNextPage")}
+ aria-disabled={pageNumber >= pageCount ? true : undefined}
onClick={goToNextPage}
>