From c952a0ac5000d9d56d2e6b048e731379dd4860aa Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:52:48 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[UX=20improvement?= =?UTF-8?q?]=20=EC=95=85=EB=B3=B4=20=EB=B7=B0=EC=96=B4=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=84=B1=20=ED=96=A5=EC=83=81=20=EB=B0=8F=20=ED=88=B4?= =?UTF-8?q?=ED=8C=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScoreViewer 컴포넌트의 아이콘 버튼(이전/다음 페이지, 확대/축소 등)들에 대해 네이티브 `disabled` 속성 대신 `aria-disabled` 속성과 `title` 툴팁을 추가하여 키보드 내비게이션 사용자와 스크린 리더 환경에서의 접근성을 개선했습니다. 또한 컴포넌트 한계치에서의 비활성화 동작을 막기 위해 클릭 이벤트 핸들러 내부에서 안전하게 실행을 차단(early return)하도록 보강했습니다. --- .Jules/palette.md | 3 ++ .../src/features/score/ScoreViewer.test.tsx | 23 +++++++++--- .../src/features/score/ScoreViewer.tsx | 35 +++++++++++++++---- 3 files changed, 51 insertions(+), 10 deletions(-) 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} >