Skip to content
Closed
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
1 change: 1 addition & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@
## 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-08-01 - Disabled icon-only buttons wrapping rule\n**Learning:** When using custom components (e.g., Base UI `<Button>`) that internally rely on the `disabled` prop to function, wrapping them in a focusable `span` is necessary to provide accessible tooltips on hover and focus. When adding tests, make sure to query these specific wrappers accurately (e.g. `screen.getAllByTitle(...)`).\n**Action:** Use a focusable `span` wrapper with `tabIndex={isDisabled ? 0 : -1}`, `title`, and `aria-disabled="true"` around `<Button>` components that must remain functionally disabled, and include `.sr-only` text.
13 changes: 10 additions & 3 deletions apps/desktop/src/features/score/ScoreView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,16 @@ describe("ScoreView", () => {
render(<ScoreView song={song} projectId={null} onSongUpdate={vi.fn()} />);

expect(screen.getByText("Scores attach to the active analysis project.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add score" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Open score: opener.pdf" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Remove: opener.pdf" })).toBeDisabled();

// Open score λ²„νŠΌμ΄ λΉ„ν™œμ„±ν™”λ¨μ„ 확인
expect(screen.getByRole("button", { name: "Open score: opener.pdf" })).toHaveAttribute("aria-disabled", "true");

const wrappers = screen.getAllByTitle("scoreNavDisabledHint");
// Add score λž˜νΌκ°€ λΉ„ν™œμ„±ν™”λ¨μ„ 확인
expect(wrappers[0]).toHaveAttribute("aria-disabled", "true");
// Remove score λž˜νΌκ°€ λΉ„ν™œμ„±ν™”λ¨μ„ 확인 (Open score λ²„νŠΌμ—λ„ title이 μžˆμœΌλ―€λ‘œ indexκ°€ 닀름)
const removeWrapper = wrappers.find(el => el.tagName.toLowerCase() === 'span' && el.querySelector('button[aria-label="Remove: opener.pdf"]'));
expect(removeWrapper).toHaveAttribute("aria-disabled", "true");

fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" }));
expect(mockInvoke).not.toHaveBeenCalled();
Expand Down
63 changes: 40 additions & 23 deletions apps/desktop/src/features/score/ScoreView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,19 +133,27 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-400">{t("scoreViewSubtitle")}</p>
</div>
<Button
onClick={projectId ? () => void handleAttach(projectId) : undefined}
disabled={!projectId || isAttaching}
variant="secondary"
className="min-h-11 border border-cyan-300/20 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20"
<span
tabIndex={!projectId ? 0 : -1}
title={!projectId ? t("scoreNavDisabledHint") : undefined}
aria-disabled={!projectId ? "true" : undefined}
className="inline-flex"
>
{isAttaching ? (
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
) : (
<FilePlus2 className="mr-2 size-4" aria-hidden="true" />
)}
{isAttaching ? t("scoreAttaching") : t("scoreAttach")}
</Button>
{(!projectId) && <span className="sr-only">{t("scoreNavDisabledHint")}</span>}
<Button
onClick={projectId ? () => void handleAttach(projectId) : undefined}
disabled={!projectId || isAttaching}
variant="secondary"
className="min-h-11 border border-cyan-300/20 bg-cyan-300/10 font-semibold text-cyan-50 hover:bg-cyan-300/20"
>
{isAttaching ? (
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
) : (
<FilePlus2 className="mr-2 size-4" aria-hidden="true" />
)}
{isAttaching ? t("scoreAttaching") : t("scoreAttach")}
</Button>
</span>
</div>

{!projectId && (
Expand Down Expand Up @@ -184,24 +192,33 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
<button
type="button"
onClick={projectId ? () => void openAttachment(projectId, attachment) : undefined}
disabled={!projectId}
aria-disabled={!projectId ? "true" : undefined}
title={!projectId ? t("scoreNavDisabledHint") : undefined}
aria-current={selected?.id === attachment.id ? "true" : undefined}
aria-label={`${t("scoreOpen")}: ${attachment.fileName}`}
className="flex min-h-10 min-w-0 flex-1 items-center gap-2 text-left text-sm font-semibold text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 disabled:cursor-not-allowed disabled:opacity-60"
className="flex min-h-10 min-w-0 flex-1 items-center gap-2 text-left text-sm font-semibold text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 aria-disabled:cursor-not-allowed aria-disabled:opacity-60"
>
<FileMusic className="size-4 shrink-0 text-cyan-300" aria-hidden="true" />
<span className="truncate">{attachment.fileName}</span>
</button>
<Button
variant="outline"
size="icon"
onClick={projectId ? () => void handleRemove(projectId, attachment) : undefined}
disabled={!projectId}
aria-label={`${t("scoreRemove")}: ${attachment.fileName}`}
className="size-10 border-rose-300/25 text-rose-200 hover:bg-rose-400/10"
<span
tabIndex={!projectId ? 0 : -1}
title={!projectId ? t("scoreNavDisabledHint") : undefined}
aria-disabled={!projectId ? "true" : undefined}
className="inline-flex"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
{(!projectId) && <span className="sr-only">{t("scoreNavDisabledHint")}</span>}
<Button
variant="outline"
size="icon"
onClick={projectId ? () => void handleRemove(projectId, attachment) : undefined}
disabled={!projectId}
aria-label={`${t("scoreRemove")}: ${attachment.fileName}`}
className="size-10 border-rose-300/25 text-rose-200 hover:bg-rose-400/10"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</span>
</li>
))}
</ul>
Expand Down
Loading