Skip to content
Merged
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
10 changes: 10 additions & 0 deletions src/components/HelpPopover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,14 @@ describe("HelpPopover", () => {
const btn = screen.getByRole("button", { name: "Help" });
expect(btn.className).toContain("help-popover-trigger");
});

it("forwards an additional className to the trigger button", () => {
render(
<HelpPopover content="Class test" className="custom-class" />
);

const btn = screen.getByRole("button", { name: "Help" });
expect(btn.className).toContain("help-popover-trigger");
expect(btn.className).toContain("custom-class");
});
});
8 changes: 7 additions & 1 deletion src/components/HelpPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,26 @@ type HelpPopoverProps = {
* @default 300
*/
hoverDelayMs?: number;
/**
* Additional CSS class forwarded to the trigger button.
* Useful for layout alignment in page headers (e.g. LatencyChart).
*/
className?: string;
};

export default function HelpPopover({
content,
ariaLabel = "Help",
hoverDelayMs = 300,
className,
}: HelpPopoverProps): JSX.Element {
const labelId = useId();

return (
<Tooltip content={content} hoverDelayMs={hoverDelayMs}>
<button
type="button"
className="help-popover-trigger"
className={`help-popover-trigger${className ? ` ${className}` : ""}`}
aria-label={ariaLabel}
aria-describedby={labelId}
style={{
Expand Down
141 changes: 141 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -6938,4 +6938,145 @@ code,
}
}

/* ── LatencyChart page (GrantFox FWC26) ───────────────────────────────────────
Responsive latency dashboard with bar chart and HelpPopover. Uses design
tokens for light/dark mode consistency. */
.latency-chart-page {
display: grid;
gap: 24px;
padding: 20px;
max-width: 1180px;
margin: 0 auto;
}

.latency-chart-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}

.latency-chart-title-group {
display: inline-flex;
align-items: center;
gap: 10px;
}

.latency-chart-title-group h1 {
margin: 0;
font-size: clamp(1.75rem, 3vw, 2.35rem);
line-height: 1.1;
}

.latency-chart-subtitle {
color: var(--muted);
font-size: 0.875rem;
}

.latency-stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}

.latency-stat-card {
padding: 18px;
border-radius: var(--radius-lg);
border: 1px solid rgba(169, 184, 255, 0.12);
background: var(--surface-soft);
}

.latency-stat-label {
display: block;
margin-bottom: 10px;
color: var(--muted);
font-size: 0.9rem;
}

.latency-stat-value {
display: block;
font-size: 1.35rem;
}

.latency-chart-container {
margin-top: 24px;
padding: 20px;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: var(--surface-soft);
min-height: 260px;
}

.latency-chart-bars {
display: flex;
align-items: flex-end;
gap: 8px;
height: 220px;
}

.latency-chart-bar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}

.latency-chart-bar {
width: 100%;
max-width: 48px;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 70%, var(--accent-strong)));
transition: opacity 180ms ease;
cursor: pointer;
}

.latency-chart-bar--peak {
background: linear-gradient(180deg, var(--danger), color-mix(in srgb, var(--danger) 70%, var(--warning)));
}

.latency-chart-bar-label {
font-size: 0.75rem;
color: var(--muted);
text-align: center;
white-space: nowrap;
}

.latency-chart-caption {
margin: 12px 0 0;
color: var(--muted);
font-size: 0.875rem;
line-height: 1.5;
}

@media (max-width: 768px) {
.latency-stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

.latency-chart-bars {
gap: 4px;
}

.latency-chart-bar-label {
font-size: 0.65rem;
}
}

@media (max-width: 480px) {
.latency-chart-page {
padding: 14px;
}

.latency-stats-grid {
grid-template-columns: 1fr;
}

.latency-chart-bars {
height: 160px;
}
}


6 changes: 6 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ThemeProvider } from "./ThemeContext";
import { CollectionsProvider } from "./state/collectionsStore";
import MarketplacePageSkeleton from "./pages/MarketplacePage.skeleton";
import ApiDetailPageSkeleton from "./pages/ApiDetailPage.skeleton";
import LatencyChart from "./pages/LatencyChart";

const root = ReactDOM.createRoot(document.getElementById("root")!);

Expand Down Expand Up @@ -74,6 +75,11 @@ async function renderRoute() {
return;
}

if (pathname.startsWith("/latency-chart")) {
root.render(wrap(<LatencyChart />));
return;
}

// Default: render the existing App
root.render(
<React.StrictMode>
Expand Down
94 changes: 94 additions & 0 deletions src/pages/LatencyChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { MemoryRouter } from "react-router-dom";
import LatencyChart from "./LatencyChart";

afterEach(cleanup);

function renderChart() {
return render(
<MemoryRouter>
<LatencyChart />
</MemoryRouter>
);
}

describe("LatencyChart Page (#714)", () => {
it("renders the page title and breadcrumb", () => {
renderChart();

expect(screen.getByRole("heading", { name: "Latency" })).toBeTruthy();
expect(screen.getByText("Dashboard")).toBeTruthy();
expect(screen.getByText("Last 24 hours")).toBeTruthy();
});

it("renders the HelpPopover with an accessible label explaining P95 latency", async () => {
renderChart();

const helpBtn = screen.getByRole("button", { name: "Help: What is P95 latency?" });
expect(helpBtn).toBeTruthy();

fireEvent.focus(helpBtn);

await waitFor(() => {
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toBeTruthy();
expect(tooltip).toHaveTextContent("P95 Latency");
expect(tooltip).toHaveTextContent("95th percentile response time");
});
});

it("renders stat cards for min, avg, P95, and max", () => {
renderChart();

expect(screen.getByText("Min")).toBeTruthy();
expect(screen.getByText("Avg")).toBeTruthy();
expect(screen.getByText("P95")).toBeTruthy();
expect(screen.getByText("Max")).toBeTruthy();
});

it("renders the correct latency values in stat cards", () => {
renderChart();

const minValue = screen.getByText("88 ms");
const maxValue = screen.getByText("210 ms");
const avgValue = screen.getByText("152 ms");
const p95Value = screen.getByText("200 ms");
expect(minValue).toBeTruthy();
expect(maxValue).toBeTruthy();
expect(avgValue).toBeTruthy();
expect(p95Value).toBeTruthy();
});

it("renders the latency chart bars with accessible labels", () => {
renderChart();

const bars = screen.getAllByRole("img", { name: /ms/ });
expect(bars.length).toBeGreaterThan(0);
});

it("renders the chart caption for screen reader context", () => {
renderChart();

expect(screen.getByText(/Bars represent sampled response times/)).toBeTruthy();
});

it("dismisses the HelpPopover tooltip on Escape", async () => {
renderChart();

const helpBtn = screen.getByRole("button", { name: "Help: What is P95 latency?" });
fireEvent.focus(helpBtn);

await waitFor(() => {
expect(screen.getByRole("tooltip")).toBeTruthy();
});

fireEvent.keyDown(document, { key: "Escape" });

await waitFor(() => {
expect(screen.queryByRole("tooltip")).toBeNull();
});
});
});
Loading
Loading