refactor(player): extract CircleButton shared primitive

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-24 23:54:36 +02:00
co-authored by Claude Opus 4.7
parent 50be1d4243
commit 63e00a498b
3 changed files with 85 additions and 46 deletions
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CircleButton } from "./CircleButton";
describe("CircleButton", () => {
it("calls onClick when clicked", async () => {
const onClick = vi.fn();
render(
<CircleButton size="sm" variant="secondary" ariaLabel="Test" onClick={onClick}>
x
</CircleButton>,
);
await userEvent.click(screen.getByRole("button", { name: "Test" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("applies the primary skin class for variant=primary", () => {
render(
<CircleButton size="md" variant="primary" ariaLabel="Play">
</CircleButton>,
);
const btn = screen.getByRole("button", { name: "Play" });
expect(btn.className).toContain("player-disc-primary");
});
it("sets data-paused when prop is true", () => {
render(
<CircleButton size="md" variant="primary" ariaLabel="Play" data-paused>
</CircleButton>,
);
expect(screen.getByRole("button")).toHaveAttribute("data-paused", "true");
});
});
@@ -0,0 +1,48 @@
export type CircleButtonProps = {
size: "sm" | "md" | "lg";
variant: "primary" | "secondary";
ariaLabel: string;
onClick?: () => void;
disabled?: boolean;
children: React.ReactNode;
"data-paused"?: boolean;
};
/**
* Glass-disc button used in both the video and audiobook player transports.
* - `primary` = glossy white disc (play/pause).
* - `secondary` = subtle glass disc (skip, prev/next).
* Sizes: `sm` 4044px in-bar secondaries, `md` 5256px in-bar play,
* `lg` 80px for floating variants (e.g. Now Listening play button).
*/
export function CircleButton({
size,
variant,
ariaLabel,
onClick,
disabled,
children,
"data-paused": dataPaused,
}: CircleButtonProps) {
const base =
"flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/75 disabled:opacity-40 disabled:cursor-not-allowed";
const sizing =
size === "lg"
? "h-20 w-20"
: size === "md"
? "h-12 w-12 sm:h-14 sm:w-14"
: "h-10 w-10 sm:h-11 sm:w-11";
const skin = variant === "primary" ? "player-disc-primary" : "player-disc-secondary";
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
disabled={disabled}
className={`${base} ${sizing} ${skin}`}
data-paused={dataPaused ? "true" : undefined}
>
{children}
</button>
);
}
+1 -46
View File
@@ -10,6 +10,7 @@ import {
SkipBack,
SkipForward,
} from "lucide-react";
import { CircleButton } from "./CircleButton";
import { SeekBar, formatTime } from "./SeekBar";
import { VolumeControl } from "./VolumeControl";
import { QualityMenu } from "./QualityMenu";
@@ -361,52 +362,6 @@ export function PlayerControls({
Internal building blocks
───────────────────────────────────────────────────────────────────── */
type CircleButtonProps = {
size: "sm" | "md" | "lg";
variant: "primary" | "secondary";
ariaLabel: string;
onClick?: () => void;
children: React.ReactNode;
"data-paused"?: boolean;
};
/**
* Pill-circle button used in the centered playback cluster.
* - `primary` = glossy white disc (play/pause).
* - `secondary` = subtle glass disc (skip, prev/next episode).
* Sizes: `sm` 4044px for in-bar secondaries, `md` 5256px for in-bar
* play button, `lg` 80px reserved for a future floating variant.
*/
function CircleButton({
size,
variant,
ariaLabel,
onClick,
children,
"data-paused": dataPaused,
}: CircleButtonProps) {
const base =
"flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/75";
const sizing =
size === "lg"
? "h-20 w-20"
: size === "md"
? "h-12 w-12 sm:h-14 sm:w-14"
: "h-10 w-10 sm:h-11 sm:w-11";
const skin = variant === "primary" ? "player-disc-primary" : "player-disc-secondary";
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={`${base} ${sizing} ${skin}`}
data-paused={dataPaused ? "true" : undefined}
>
{children}
</button>
);
}
/** Invisible placeholder that reserves the exact footprint of a CircleButton
* so the playback cluster stays symmetric when a neighboring episode isn't
* available (first/last episode in a series). */