diff --git a/web/src/player/components/CircleButton.test.tsx b/web/src/player/components/CircleButton.test.tsx new file mode 100644 index 00000000..f1d81657 --- /dev/null +++ b/web/src/player/components/CircleButton.test.tsx @@ -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( + + x + , + ); + await userEvent.click(screen.getByRole("button", { name: "Test" })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("applies the primary skin class for variant=primary", () => { + render( + + ▶ + , + ); + const btn = screen.getByRole("button", { name: "Play" }); + expect(btn.className).toContain("player-disc-primary"); + }); + + it("sets data-paused when prop is true", () => { + render( + + ▶ + , + ); + expect(screen.getByRole("button")).toHaveAttribute("data-paused", "true"); + }); +}); diff --git a/web/src/player/components/CircleButton.tsx b/web/src/player/components/CircleButton.tsx new file mode 100644 index 00000000..9a0d0b51 --- /dev/null +++ b/web/src/player/components/CircleButton.tsx @@ -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 ( + + ); +} diff --git a/web/src/player/components/PlayerControls.tsx b/web/src/player/components/PlayerControls.tsx index 6d224765..5d921592 100644 --- a/web/src/player/components/PlayerControls.tsx +++ b/web/src/player/components/PlayerControls.tsx @@ -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 ( - - ); -} - /** 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). */