diff --git a/apps/desktop/src/features/workspace/PracticeProgress.test.tsx b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx index 2da2f3855..ee2c12533 100644 --- a/apps/desktop/src/features/workspace/PracticeProgress.test.tsx +++ b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx @@ -1,111 +1,149 @@ -import { createEvent, fireEvent, render, screen } from "@testing-library/react"; +import { act, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { PracticeProgress } from "./PracticeProgress"; +import userEvent from "@testing-library/user-event"; -// Mock the i18n functions vi.mock("../../i18n", () => ({ createTranslator: () => (key: string) => key, detectPreferredLocale: () => "en-US", })); describe("PracticeProgress", () => { - it("renders with default progress 0 when no progress is provided", () => { + it("renders the minimum boundary with a persistent accessible reason", () => { const handleChange = vi.fn(); render(); expect(screen.getByText("0%")).toBeTruthy(); - const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }) as HTMLButtonElement; + const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }); expect(decreaseBtn).toHaveAttribute("aria-disabled", "true"); + expect(decreaseBtn).not.toHaveAttribute("title"); + + const descriptionId = decreaseBtn.getAttribute("aria-describedby"); + expect(descriptionId).toBeTruthy(); + expect(document.getElementById(descriptionId ?? "")).toHaveTextContent("practiceProgressAtMin"); const clickEvent = createEvent.click(decreaseBtn); fireEvent(decreaseBtn, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); + expect(handleChange).not.toHaveBeenCalled(); }); - it("renders provided progress", () => { + it("uses the visible practice-progress label as the slider accessible name", () => { const handleChange = vi.fn(); render(); + expect(screen.getByRole("slider", { name: "practiceProgressLabel" })).toBeInTheDocument(); expect(screen.getByText("50%")).toBeTruthy(); }); - it("calls onChange with increased value when increase button is clicked", () => { + it("shows the boundary tooltip on keyboard focus and dismisses it with Escape", async () => { const handleChange = vi.fn(); - render(); + render(); - const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }); - fireEvent.click(increaseBtn); - - expect(handleChange).toHaveBeenCalledWith(60); + const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }); + fireEvent.focus(decreaseBtn); + + expect( + await screen.findByText("decreasePracticeProgressLabel: practiceProgressAtMin"), + ).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => { + expect( + screen.queryByText("decreasePracticeProgressLabel: practiceProgressAtMin"), + ).not.toBeInTheDocument(); + }); }); - it("calls onChange with decreased value when decrease button is clicked", () => { + it("shows the action tooltip after pointer hover", async () => { const handleChange = vi.fn(); render(); - const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }); - fireEvent.click(decreaseBtn); + const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }); + await userEvent.hover(increaseBtn); - expect(handleChange).toHaveBeenCalledWith(40); + expect(await screen.findByText("increasePracticeProgressLabel")).toBeInTheDocument(); }); - it("does not exceed 100 when increasing", () => { + it("changes progress from the increment and decrement controls", () => { const handleChange = vi.fn(); - render(); + render(); - const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }); - fireEvent.click(increaseBtn); + fireEvent.click(screen.getByRole("button", { name: "increasePracticeProgressLabel" })); + expect(handleChange).toHaveBeenLastCalledWith(60); - expect(handleChange).toHaveBeenCalledWith(100); + fireEvent.click(screen.getByRole("button", { name: "decreasePracticeProgressLabel" })); + expect(handleChange).toHaveBeenLastCalledWith(40); }); - it("does not go below 0 when decreasing", () => { - const handleChange = vi.fn(); - render(); + it("clamps button changes to the 0-100 range", () => { + const handleIncrease = vi.fn(); + const { rerender } = render(); - const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }); - fireEvent.click(decreaseBtn); + fireEvent.click(screen.getByRole("button", { name: "increasePracticeProgressLabel" })); + expect(handleIncrease).toHaveBeenCalledWith(100); - expect(handleChange).toHaveBeenCalledWith(0); + const handleDecrease = vi.fn(); + rerender(); + fireEvent.click(screen.getByRole("button", { name: "decreasePracticeProgressLabel" })); + expect(handleDecrease).toHaveBeenCalledWith(0); }); - it("calls onChange when slider is changed", () => { + it("changes progress through the Base UI slider keyboard contract", async () => { const handleChange = vi.fn(); render(); - const slider = screen.getByRole("slider"); - fireEvent.change(slider, { target: { value: "75" } }); + const slider = screen.getByRole("slider", { name: "practiceProgressLabel" }); + await act(async () => { + slider.focus(); + fireEvent.keyDown(slider, { key: "ArrowRight", code: "ArrowRight" }); + }); - expect(handleChange).toHaveBeenCalledWith(75); + expect(handleChange).toHaveBeenCalledWith(51); }); - it("keeps focus on interactive controls instead of the progress region", () => { + it("keeps 44 CSS px interaction envelopes without making the visible track oversized", () => { const handleChange = vi.fn(); - render(); + const { container } = render(); - expect(screen.getByRole("region", { name: "practiceProgressRegionLabel" })).not.toHaveAttribute("tabindex"); - expect(screen.getByRole("slider")).toBeInTheDocument(); + const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }); + const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }); + const slider = screen.getByRole("slider", { name: "practiceProgressLabel" }); + const thumb = slider.parentElement; + const control = container.querySelector('[data-slot="slider-control"]'); + const track = container.querySelector('[data-slot="slider-track"]'); + + expect(decreaseBtn).toHaveClass("size-11"); + expect(increaseBtn).toHaveClass("size-11"); + expect(control).toHaveClass("h-11", "min-h-11"); + expect(track).toHaveClass("h-3"); + expect(track).not.toHaveClass("overflow-hidden"); + expect(thumb).toHaveClass("after:inset-[-12px]"); }); - it("ignores invalid slider input gracefully", () => { + it("keeps focus on interactive controls instead of the progress region", () => { const handleChange = vi.fn(); render(); - const slider = screen.getByRole("slider"); - fireEvent.change(slider, { target: { value: "invalid" } }); - - expect(handleChange).not.toHaveBeenCalled(); + expect(screen.getByRole("region", { name: "practiceProgressRegionLabel" })).not.toHaveAttribute("tabindex"); + expect(screen.getByRole("slider", { name: "practiceProgressLabel" })).toBeInTheDocument(); }); - it("disables increase button when progress is 100", () => { + it("keeps the maximum boundary focusable with a persistent accessible reason", () => { const handleChange = vi.fn(); render(); - const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }) as HTMLButtonElement; + const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }); expect(increaseBtn).toHaveAttribute("aria-disabled", "true"); + expect(increaseBtn).not.toHaveAttribute("title"); + + const descriptionId = increaseBtn.getAttribute("aria-describedby"); + expect(descriptionId).toBeTruthy(); + expect(document.getElementById(descriptionId ?? "")).toHaveTextContent("practiceProgressAtMax"); const clickEvent = createEvent.click(increaseBtn); fireEvent(increaseBtn, clickEvent); expect(clickEvent.defaultPrevented).toBe(true); + expect(handleChange).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/features/workspace/PracticeProgress.tsx b/apps/desktop/src/features/workspace/PracticeProgress.tsx index d10ca94c0..16144494b 100644 --- a/apps/desktop/src/features/workspace/PracticeProgress.tsx +++ b/apps/desktop/src/features/workspace/PracticeProgress.tsx @@ -1,16 +1,29 @@ -import { memo, useCallback } from "react"; +import { memo, useCallback, useId } from "react"; import { Minus, Plus } from "lucide-react"; +import { + Slider, + SliderControl, + SliderIndicator, + SliderLabel, + SliderThumb, + SliderTrack, +} from "../../components/ui/slider"; import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -/** Documented. */ +/** Properties for the rehearsal practice-progress control. */ interface PracticeProgressProps { progress?: number; onChange: (newProgress: number) => void; } -/** Documented. */ +/** Render the rehearsal practice-progress controls and accessible boundary cues. */ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressProps) { const t = createTranslator(detectPreferredLocale()); + const decreaseLimitDescriptionId = useId(); + const increaseLimitDescriptionId = useId(); + const atMinimum = progress <= 0; + const atMaximum = progress >= 100; const handleDecrease = useCallback((e: React.MouseEvent) => { if (progress <= 0) { @@ -28,69 +41,91 @@ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressP onChange(Math.min(100, progress + 10)); }, [progress, onChange]); - const handleSliderChange = useCallback((e: React.ChangeEvent) => { - const value = parseInt(e.target.value, 10); - if (!Number.isNaN(value)) { - onChange(Math.max(0, Math.min(100, value))); + const handleSliderChange = useCallback((value: number | readonly number[]) => { + const numericValue = Array.isArray(value) ? value[0] : value; + if (typeof numericValue !== "number" || !Number.isFinite(numericValue)) { + return; } + onChange(Math.max(0, Math.min(100, numericValue))); }, [onChange]); + const decreaseActionLabel = t("decreasePracticeProgressLabel"); + const increaseActionLabel = t("increasePracticeProgressLabel"); + const minimumReason = t("practiceProgressAtMin"); + const maximumReason = t("practiceProgressAtMax"); + return (
-
- - {progress}% -
+ +
+ + {t("practiceProgressLabel")} + + {progress}% +
-
- +
+ + + + + {atMinimum ? `${decreaseActionLabel}: ${minimumReason}` : decreaseActionLabel} + + + {atMinimum ? ( + + {minimumReason} + + ) : null} -
-
- -
+ + + + + + - -
+ + + + + {atMaximum ? `${increaseActionLabel}: ${maximumReason}` : increaseActionLabel} + + + {atMaximum ? ( + + {maximumReason} + + ) : null} +
+
); } diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..2a3c549d3 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,8 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "practiceProgressAtMin": "Progress is already at 0%.", + "practiceProgressAtMax": "Progress is already at 100%.", "workspaceFirstRangeTitle": "Tonight's first range", "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..8f4d98f88 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,8 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "practiceProgressAtMin": "진척도가 이미 0%입니다.", + "practiceProgressAtMax": "진척도가 이미 100%입니다.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",