Skip to content
Open
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
35 changes: 35 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
124 changes: 121 additions & 3 deletions src/pages/SubscribeCTA.test.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand All @@ -30,7 +36,7 @@ describe("SubscribeCTA Component", () => {
});

it("renders API details and the subscribe button correctly", () => {
render(
const { container } = render(
<SubscribeCTA
apiName="WeatherSim API"
pricePerRequest={0.01}
Expand Down Expand Up @@ -87,4 +93,116 @@ describe("SubscribeCTA Component", () => {
});
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(
<SubscribeCTA
apiName="WeatherSim API"
pricePerRequest={0.01}
observeElementSelector=".api-hero__cta--detail"
/>
);
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();
});
});
});
47 changes: 46 additions & 1 deletion src/pages/SubscribeCTA.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -66,6 +94,23 @@ export default function SubscribeCTA({
</div>
</div>

<div className="subscribe-cta-bar__icon-group">
<Tooltip
content={linkCopied ? "Link copied!" : "Copy link"}
hoverDelayMs={300}
longPressMs={500}
>
<button
type="button"
className="subscribe-cta-bar__icon-button"
aria-label={linkCopied ? "Link copied!" : "Copy link to this API"}
onClick={handleCopyLink}
>
<LinkIcon size={16} aria-hidden="true" />
</button>
</Tooltip>
</div>

<SubscribeButton
apiName={apiName}
onSubscribe={onSubscribe}
Expand Down
Loading