diff --git a/src/index.css b/src/index.css index 1b3ba58..707477b 100644 --- a/src/index.css +++ b/src/index.css @@ -6622,6 +6622,41 @@ code, color: var(--text); } +.subscribe-cta-bar__icon-group { + display: flex; + align-items: center; + gap: 8px; +} + +.subscribe-cta-bar__icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid var(--line); + border-radius: 0.375rem; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: + color var(--transition-speed), + background var(--transition-speed), + border-color var(--transition-speed); +} + +.subscribe-cta-bar__icon-button:hover { + color: var(--text); + background: color-mix(in srgb, var(--text) 8%, transparent); + border-color: var(--line-strong); +} + +.subscribe-cta-bar__icon-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* Responsive adjustments for smaller screens */ @media (max-width: 640px) { .subscribe-cta-bar { diff --git a/src/pages/SubscribeCTA.test.tsx b/src/pages/SubscribeCTA.test.tsx index e2d8270..55eaa39 100644 --- a/src/pages/SubscribeCTA.test.tsx +++ b/src/pages/SubscribeCTA.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, act } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, it, expect, vi, beforeEach } from "vitest"; import SubscribeCTA from "./SubscribeCTA"; // Mock IntersectionObserver @@ -20,6 +20,12 @@ Object.defineProperty(window, "IntersectionObserver", { }); describe("SubscribeCTA Component", () => { + afterEach(() => { + cleanup(); + // Remove dummy CTA elements added during tests + document.querySelectorAll(".api-hero__cta--detail").forEach((el) => el.remove()); + }); + beforeEach(() => { vi.clearAllMocks(); observerCallback = null; @@ -30,7 +36,7 @@ describe("SubscribeCTA Component", () => { }); it("renders API details and the subscribe button correctly", () => { - render( + const { container } = render( { }); expect(ctaBar?.classList.contains("subscribe-cta-bar--visible")).toBe(false); }); + + // ── Tooltip Primitive Integration (issue #746) ──────────────────────────── + + describe("Tooltip primitive on icon buttons", () => { + function setup() { + const utils = render( + + ); + const copyButton = screen.getByRole("button", { name: /copy link/i }); + return { ...utils, copyButton }; + } + + it("tooltip is hidden by default", () => { + setup(); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("shows tooltip on hover (after hover delay) and hides on leave", () => { + vi.useFakeTimers(); + const { copyButton } = setup(); + + fireEvent.mouseEnter(copyButton); + // Not visible before delay elapses + expect(screen.queryByRole("tooltip")).toBeNull(); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(screen.getByRole("tooltip")).toBeTruthy(); + + fireEvent.mouseLeave(copyButton); + expect(screen.queryByRole("tooltip")).toBeNull(); + + vi.useRealTimers(); + }); + + it("shows tooltip on keyboard focus and links via aria-describedby", () => { + const { copyButton } = setup(); + + fireEvent.focus(copyButton); + const tip = screen.getByRole("tooltip"); + expect(copyButton.getAttribute("aria-describedby")).toBe(tip.id); + }); + + it("dismisses tooltip on Escape", () => { + vi.useFakeTimers(); + const { copyButton } = setup(); + + fireEvent.mouseEnter(copyButton); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(screen.getByRole("tooltip")).toBeTruthy(); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("tooltip")).toBeNull(); + + vi.useRealTimers(); + }); + + it("respects hoverDelayMs before showing tooltip", () => { + vi.useFakeTimers(); + const { copyButton } = setup(); + + fireEvent.mouseEnter(copyButton); + expect(screen.queryByRole("tooltip")).toBeNull(); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(screen.getByRole("tooltip")).toBeTruthy(); + + vi.useRealTimers(); + }); + + it("cancels hover delay if mouse leaves before timer finishes", () => { + vi.useFakeTimers(); + const { copyButton } = setup(); + + fireEvent.mouseEnter(copyButton); + act(() => { + vi.advanceTimersByTime(150); + }); + fireEvent.mouseLeave(copyButton); + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(screen.queryByRole("tooltip")).toBeNull(); + + vi.useRealTimers(); + }); + + it("opens tooltip on touch long-press", () => { + vi.useFakeTimers(); + const { copyButton } = setup(); + + fireEvent.touchStart(copyButton); + expect(screen.queryByRole("tooltip")).toBeNull(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByRole("tooltip")).toBeTruthy(); + + vi.useRealTimers(); + }); + }); }); diff --git a/src/pages/SubscribeCTA.tsx b/src/pages/SubscribeCTA.tsx index 4417ac7..0c7fb9b 100644 --- a/src/pages/SubscribeCTA.tsx +++ b/src/pages/SubscribeCTA.tsx @@ -1,7 +1,24 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import SubscribeButton from "../components/SubscribeButton"; +import Tooltip from "../components/Tooltip"; +import { LinkIcon } from "../components/icons/LinkIcon"; import { formatPrice } from "../utils/format"; +/** + * SubscribeCTA — a sticky bottom bar that appears when the hero CTA scrolls + * out of view. + * + * Accessibility (WCAG 2.1 AA): + * - Icon-only buttons (copy link) are wrapped in the shared {@link Tooltip} + * primitive so every user gets a visible label. + * - Tooltips open on hover (300 ms delay), on keyboard focus, and on touch + * long-press (500 ms) — consistent with Breadcrumb #726 and ApiTagFilter #533. + * - `aria-describedby` connects the trigger to the tooltip content. + * - Escape dismisses an open tooltip. + * - Colours come from design tokens so they work in both light and dark mode. + * + * Part of GrantFox FWC26 campaign (issue #746). + */ type Props = { /** Name of the API. */ apiName: string; @@ -20,6 +37,17 @@ export default function SubscribeCTA({ observeElementSelector = ".api-hero__cta", }: Props): JSX.Element { const [isVisible, setIsVisible] = useState(false); + const [linkCopied, setLinkCopied] = useState(false); + + const handleCopyLink = useCallback(async () => { + try { + await navigator.clipboard.writeText(window.location.href); + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + } catch { + // Fallback for environments without clipboard API + } + }, []); useEffect(() => { const target = document.querySelector(observeElementSelector); @@ -66,6 +94,23 @@ export default function SubscribeCTA({ +
+ + + +
+