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} >