refactor(player): extract CircleButton shared primitive
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
50be1d4243
commit
63e00a498b
@@ -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` 40–44px in-bar secondaries, `md` 52–56px 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>
|
||||
);
|
||||
}
|
||||
@@ -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` 40–44px for in-bar secondaries, `md` 52–56px 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). */
|
||||
|
||||
Reference in New Issue
Block a user