From 4127cd7d7c541321849006c071602ed8f4d22977 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 4 Sep 2026 04:05:34 +0000
Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20titles=20to=20d?=
=?UTF-8?q?isabled=20navigation=20buttons=20in=20ScoreViewer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/palette.md | 3 +++
apps/desktop/src/features/score/ScoreViewer.test.tsx | 12 ++++++++----
apps/desktop/src/features/score/ScoreViewer.tsx | 10 ++++++----
apps/desktop/src/locales/en/common.json | 4 +++-
apps/desktop/src/locales/ko/common.json | 4 +++-
5 files changed, 23 insertions(+), 10 deletions(-)
diff --git a/.jules/palette.md b/.jules/palette.md
index c05638899..a01eb1d66 100644
--- a/.jules/palette.md
+++ b/.jules/palette.md
@@ -1,3 +1,6 @@
## 2024-05-19 - Replace HTML disabled with aria-disabled="true" for Accessible Tooltips
**Learning:** Native HTML `disabled` attributes completely hide elements from screen readers and block all pointer/hover events, preventing tooltips from functioning for disabled elements.
**Action:** Replace `disabled` with `aria-disabled="true"`, enforce block click handlers via `e.preventDefault()`, and add a title tooltip directly to the element to maintain full tooltip accessibility and keyboard focus support for visually impaired and mouse users.
+## 2023-10-27 - Added disabled titles to ScoreViewer navigation
+**Learning:** Added tooltips explicitly explaining *why* navigation buttons in ScoreViewer are disabled (e.g. "Already at the first page" or "Already at the last page"), which provides significantly better a11y context than leaving them silently disabled, matching the UX guidance pattern. Also modified tests accordingly to assert for the right translated keys.
+**Action:** When converting HTML `disabled` attributes to `aria-disabled="true"` for interactive elements, always consider if there is an opportunity to communicate the reason for the disabled state to the user via a `title` or `aria-describedby` attribute, and ensure translations and testing are properly set up for this specific state.
diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx
index 3ac2dd605..84ec67fd3 100644
--- a/apps/desktop/src/features/score/ScoreViewer.test.tsx
+++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx
@@ -120,8 +120,10 @@ 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: "Previous page" })).toHaveAttribute("title", "scoreViewerAlreadyFirstPage");
+ expect(screen.getByRole("button", { name: "Next page" })).not.toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByRole("button", { name: "Next page" })).not.toHaveAttribute("title", "scoreViewerAlreadyLastPage");
});
it("shows the file name when provided", async () => {
@@ -174,14 +176,16 @@ 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).toHaveAttribute("title", "scoreViewerAlreadyFirstPage");
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).toHaveAttribute("title", "scoreViewerAlreadyLastPage");
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..638e4ba95 100644
--- a/apps/desktop/src/features/score/ScoreViewer.tsx
+++ b/apps/desktop/src/features/score/ScoreViewer.tsx
@@ -292,8 +292,9 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
size="icon-lg"
className="size-14"
aria-label={t("scoreViewerPrevPage")}
- disabled={pageNumber <= 1}
- onClick={goToPreviousPage}
+ aria-disabled={pageNumber <= 1 ? true : undefined}
+ title={pageNumber <= 1 ? t("scoreViewerAlreadyFirstPage") : undefined}
+ onClick={pageNumber <= 1 ? undefined : goToPreviousPage}
>
@@ -305,8 +306,9 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
size="icon-lg"
className="size-14"
aria-label={t("scoreViewerNextPage")}
- disabled={pageNumber >= pageCount}
- onClick={goToNextPage}
+ aria-disabled={pageNumber >= pageCount ? true : undefined}
+ title={pageNumber >= pageCount ? t("scoreViewerAlreadyLastPage") : undefined}
+ onClick={pageNumber >= pageCount ? undefined : goToNextPage}
>
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..52fa2207c 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -154,5 +154,7 @@
"workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.",
"workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.",
"sectionRangeLabel": "Range",
- "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
+ "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}.",
+ "scoreViewerAlreadyFirstPage": "Already at the first page",
+ "scoreViewerAlreadyLastPage": "Already at the last page"
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..b555579bc 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -154,5 +154,7 @@
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
"sectionRangeLabel": "음역",
- "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
+ "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.",
+ "scoreViewerAlreadyFirstPage": "이미 첫 페이지입니다",
+ "scoreViewerAlreadyLastPage": "이미 마지막 페이지입니다"
}