Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 속성을 μ‚¬μš©ν•˜μ—¬ 포컀슀 이동을 ν—ˆμš©ν•˜κ³  접근성을 λ†’μ΄λ©΄μ„œ, 이벀트 ν•Έλ“€λŸ¬ μ•ˆμ— μ‘°κΈ° 리턴 λ‘œμ§μ„ μΆ”κ°€ν•˜μ—¬ λ™μž‘μ„ λ§‰μœΌμ„Έμš”.
23 changes: 19 additions & 4 deletions apps/desktop/src/features/score/ScoreViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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");
});
Expand Down
35 changes: 29 additions & 6 deletions apps/desktop/src/features/score/ScoreViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};
Expand Down Expand Up @@ -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}
>
<ZoomOut aria-hidden="true" />
Expand All @@ -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}
>
<ZoomIn aria-hidden="true" />
Expand All @@ -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}
>
Expand All @@ -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}
>
<ChevronLeft className="size-6" aria-hidden="true" />
Expand All @@ -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}
>
<ChevronRight className="size-6" aria-hidden="true" />
Expand Down
Loading