diff --git a/src/hooks/useReducedMotion.ts b/src/hooks/useReducedMotion.ts new file mode 100644 index 0000000..e590469 --- /dev/null +++ b/src/hooks/useReducedMotion.ts @@ -0,0 +1,55 @@ +/** + * useReducedMotion + * + * Returns `true` when the user's OS/browser has + * `prefers-reduced-motion: reduce` set, and `false` otherwise. + * + * The hook subscribes to changes so the value stays in sync if the + * preference is toggled while the page is open (e.g. via system settings). + * + * Usage: + * const reducedMotion = useReducedMotion(); + * // suppress transform/transition classes when true + */ + +import { useEffect, useState } from "react"; + +/** Media query string for the reduced-motion preference. */ +const QUERY = "(prefers-reduced-motion: reduce)"; + +export function useReducedMotion(): boolean { + // Initialise from the media query so there is no first-render flash. + const [matches, setMatches] = useState(() => { + // window.matchMedia may be unavailable in SSR / test environments that + // do not mock it. Fall back to `false` (full-motion) in that case. + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + return window.matchMedia(QUERY).matches; + }); + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return; + } + + const mql = window.matchMedia(QUERY); + + // Keep state in sync with live changes (e.g. user toggles OS setting). + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + + // Use the modern addEventListener API where available; fall back to the + // deprecated addListener for older browsers. + if (typeof mql.addEventListener === "function") { + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mql as any).addListener(handler); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return () => (mql as any).removeListener(handler); + } + }, []); + + return matches; +} diff --git a/src/index.css b/src/index.css index 1b3ba58..39299ed 100644 --- a/src/index.css +++ b/src/index.css @@ -2179,6 +2179,50 @@ code, color: #ffffff; } +/* + * Reduced-motion static state — GrantFox FWC26 + * + * When `prefers-reduced-motion: reduce` is set the user has expressed a + * preference for minimal animation. The `.tag-chip--no-motion` modifier is + * applied by the `ApiTagFilter` component (via the `useReducedMotion` hook) + * and here we strip out every animated property so chips jump directly to + * their final state with no easing or positional shift. + * + * Two coverage paths: + * 1. CSS-only: `@media (prefers-reduced-motion: reduce)` targets any + * `.tag-chip` element regardless of JS, providing a CSS-level safety net. + * 2. JS modifier: `.tag-chip--no-motion` is applied by the React component so + * the override is also effective in environments where the media query may + * not cascade correctly (e.g. CSS-in-JS isolation, shadow DOM). + */ +@media (prefers-reduced-motion: reduce) { + .tag-chip { + transition: none; + transform: none; + } + + .tag-chip:hover, + .tag-chip:focus-visible { + transform: none; + } +} + +/* JS-side modifier mirror — applied by useReducedMotion hook in ApiTagFilter. */ +.tag-chip--no-motion, +.tag-chip--no-motion:hover, +.tag-chip--no-motion:focus-visible { + transition: none; + transform: none; +} + +/* ApiTagFilter wrapper */ +.api-tag-filter { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + .api-card__stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/src/pages/ApiTagFilter.test.tsx b/src/pages/ApiTagFilter.test.tsx index 68f9888..e0ea923 100644 --- a/src/pages/ApiTagFilter.test.tsx +++ b/src/pages/ApiTagFilter.test.tsx @@ -1,4 +1,51 @@ // @vitest-environment jsdom +/** + * ApiTagFilter.test.tsx + * + * Focused tests for src/pages/ApiTagFilter.tsx covering: + * - Normal rendering and interaction + * - prefers-reduced-motion: reduce static state (GrantFox FWC26) + * - Accessibility attributes + * - Edge cases (empty tags, case-insensitive active matching) + */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ApiTagFilter from "./ApiTagFilter"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Override window.matchMedia to simulate a specific reduced-motion preference. + * + * The setupTests.ts global mock always returns `matches: false`. These tests + * need to control the value per-test so we replace the mock inline and restore + * it afterwards. + */ +function mockMatchMedia(prefersReducedMotion: boolean) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: + query === "(prefers-reduced-motion: reduce)" ? prefersReducedMotion : false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => false), + }), + }); +} + +const SAMPLE_TAGS = ["payments", "finance", "auth"]; + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +56,280 @@ const MOCK_TAGS = ["weather", "geo", "forecast", "payments", "cards"]; describe("ApiTagFilter", () => { afterEach(() => { cleanup(); + // Reset to the global default (no reduced-motion) after every test. + mockMatchMedia(false); + }); + + // ── Rendering ───────────────────────────────────────────────────────────── + + describe("rendering", () => { + it("renders a chip for each supplied tag", () => { + render( + , + ); + + for (const tag of SAMPLE_TAGS) { + expect(screen.getByTestId(`tag-chip-${tag}`)).toBeTruthy(); + } + }); + + it("renders nothing when tags array is empty", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders the wrapper with role='group' and default aria-label", () => { + render( + , + ); + const group = screen.getByRole("group", { name: "Filter by tag" }); + expect(group).toBeTruthy(); + }); + + it("uses a custom label when provided", () => { + render( + , + ); + expect(screen.getByRole("group", { name: "Browse by tag" })).toBeTruthy(); + }); + + it("renders each chip as a button", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "Filter by tag rpc" })).toBeTruthy(); + }); + }); + + // ── Active state ────────────────────────────────────────────────────────── + + describe("active state", () => { + it("marks the active tag chip with aria-pressed='true'", () => { + render( + , + ); + const btn = screen.getByTestId("tag-chip-finance"); + expect(btn.getAttribute("aria-pressed")).toBe("true"); + }); + + it("marks inactive chips with aria-pressed='false'", () => { + render( + , + ); + expect( + screen.getByTestId("tag-chip-payments").getAttribute("aria-pressed"), + ).toBe("false"); + }); + + it("applies the tag-chip--active class only to the active chip", () => { + render( + , + ); + const active = screen.getByTestId("tag-chip-auth"); + const inactive = screen.getByTestId("tag-chip-payments"); + + expect(active.className).toContain("tag-chip--active"); + expect(inactive.className).not.toContain("tag-chip--active"); + }); + + it("matches active tag case-insensitively", () => { + render( + , + ); + const chip = screen.getByTestId("tag-chip-Payments"); + expect(chip.getAttribute("aria-pressed")).toBe("true"); + }); + }); + + // ── Interaction ─────────────────────────────────────────────────────────── + + describe("interaction", () => { + it("calls onTagClick with the tag string when an inactive chip is clicked", () => { + const onTagClick = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("tag-chip-payments")); + expect(onTagClick).toHaveBeenCalledWith("payments"); + }); + + it("calls onTagClick with null when the active chip is clicked (deselect)", () => { + const onTagClick = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("tag-chip-finance")); + expect(onTagClick).toHaveBeenCalledWith(null); + }); + + it("does not call onTagClick with the previous active tag when switching", () => { + const onTagClick = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("tag-chip-auth")); + expect(onTagClick).toHaveBeenCalledWith("auth"); + expect(onTagClick).not.toHaveBeenCalledWith("finance"); + }); + }); + + // ── Reduced-motion static state ─────────────────────────────────────────── + // + // This block directly tests the GrantFox FWC26 requirement: + // "static state under prefers-reduced-motion: reduce for ApiTagFilter + // animations". + // + // When the OS/browser reports `prefers-reduced-motion: reduce` the component + // must add the `tag-chip--no-motion` modifier class to every chip so that + // the CSS `@media (prefers-reduced-motion: reduce)` rule (and the explicit + // `.tag-chip--no-motion` rule) can strip transitions and transforms. + + describe("prefers-reduced-motion: reduce", () => { + beforeEach(() => { + // Simulate the OS/browser reduced-motion preference before each test in + // this block. + mockMatchMedia(true); + }); + + it("adds tag-chip--no-motion to every chip when reduced-motion is preferred", () => { + render( + , + ); + + for (const tag of SAMPLE_TAGS) { + const chip = screen.getByTestId(`tag-chip-${tag}`); + expect(chip.className).toContain("tag-chip--no-motion"); + } + }); + + it("adds tag-chip--no-motion even when a chip is active", () => { + render( + , + ); + + const activeChip = screen.getByTestId("tag-chip-auth"); + // Both modifiers must be present simultaneously. + expect(activeChip.className).toContain("tag-chip--active"); + expect(activeChip.className).toContain("tag-chip--no-motion"); + }); + + it("does NOT add tag-chip--no-motion when reduced-motion is not preferred", () => { + // Explicitly set to full-motion (this is also the default mock). + mockMatchMedia(false); + + render( + , + ); + + for (const tag of SAMPLE_TAGS) { + const chip = screen.getByTestId(`tag-chip-${tag}`); + expect(chip.className).not.toContain("tag-chip--no-motion"); + } + }); + + it("still fires onTagClick correctly when reduced-motion is active", () => { + const onTagClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId("tag-chip-finance")); + expect(onTagClick).toHaveBeenCalledWith("finance"); + }); + + it("still allows deselection when reduced-motion is active", () => { + const onTagClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId("tag-chip-finance")); + expect(onTagClick).toHaveBeenCalledWith(null); + }); + }); + + // ── Accessibility ───────────────────────────────────────────────────────── + + describe("accessibility", () => { + it("each chip has an accessible label that includes the tag name", () => { + render( + , + ); + expect(screen.getByLabelText("Filter by tag rpc")).toBeTruthy(); + expect(screen.getByLabelText("Filter by tag rest")).toBeTruthy(); + }); + + it("chips are reachable by keyboard tab order (not disabled)", () => { + render( + , + ); + for (const tag of SAMPLE_TAGS) { + const chip = screen.getByTestId(`tag-chip-${tag}`); + expect(chip.getAttribute("disabled")).toBeNull(); + } + }); + + it("wrapper has data-testid='api-tag-filter' for integration tests", () => { + render( + , + ); + expect(screen.getByTestId("api-tag-filter")).toBeTruthy(); + }); }); // ── Rendering ────────────────────────────────────────────────────────── diff --git a/src/pages/ApiTagFilter.tsx b/src/pages/ApiTagFilter.tsx index b2793a8..c2c5231 100644 --- a/src/pages/ApiTagFilter.tsx +++ b/src/pages/ApiTagFilter.tsx @@ -1,3 +1,134 @@ +/** + * ApiTagFilter + * + * Renders a horizontal scrollable strip of tag-chip buttons that let the user + * filter the API list by a single tag. The component is fully keyboard + * accessible (arrow-key navigation, Enter/Space to toggle) and WCAG 2.1 AA + * compliant. + * + * ## Reduced-motion behaviour (GrantFox FWC26) + * + * When `prefers-reduced-motion: reduce` is active the hover/focus animation + * that lifts a chip upward (`transform: translateY(-1px)`) and the CSS + * `transition` easing are suppressed. This is achieved in two complementary + * ways: + * + * 1. The `useReducedMotion` hook adds the `tag-chip--no-motion` modifier + * class to every chip so that inline-style overrides can be applied + * entirely in CSS with a single `@media (prefers-reduced-motion: reduce)` + * rule (see `index.css`). + * + * 2. The CSS rule (in `index.css`) removes `transition` and `transform` for + * `.tag-chip--no-motion` so the chip reaches its final visual state + * instantly. + * + * Props: + * - `tags` – ordered list of tag strings to render + * - `activeTag` – currently selected tag, or `null` for no filter + * - `onTagClick` – callback invoked with the tag string when a chip is + * clicked; clicking the active tag passes `null` to deselect + * - `label` – optional accessible label for the group (default: + * "Filter by tag") + */ + +import { useReducedMotion } from "../hooks/useReducedMotion"; + +export interface ApiTagFilterProps { + /** Ordered list of tag strings to display as filter chips. */ + tags: string[]; + /** The currently active tag filter, or `null` when no tag is selected. */ + activeTag: string | null; + /** + * Called when the user clicks a chip. + * Receives `null` when the user clicks the already-active tag (deselects). + */ + onTagClick: (tag: string | null) => void; + /** Accessible label for the filter group element. Defaults to "Filter by tag". */ + label?: string; +} + +/** + * ApiTagFilter — tag-chip filter bar with prefers-reduced-motion support. + * + * When no tags are provided the component renders nothing (returns `null`). + */ +export default function ApiTagFilter({ + tags, + activeTag, + onTagClick, + label = "Filter by tag", +}: ApiTagFilterProps): JSX.Element | null { + // Detect user's reduced-motion preference. + // When `true` the `tag-chip--no-motion` modifier is applied, which the CSS + // uses to suppress transitions and transforms (see index.css). + const reducedMotion = useReducedMotion(); + + // Nothing to render if the parent hasn't provided any tags. + if (tags.length === 0) return null; + + /** + * Build the full class string for a single chip. + * + * Classes applied: + * - `tag-chip` – base chip styles (always present) + * - `tag-chip--active` – filled accent style when this tag is selected + * - `tag-chip--no-motion` – strips transition/transform under reduced-motion + */ + const chipClass = (tag: string): string => { + const isActive = + activeTag !== null && activeTag.toLowerCase() === tag.toLowerCase(); + + return [ + "tag-chip", + isActive ? "tag-chip--active" : "", + // Add reduced-motion modifier so CSS @media rule can target it. + reducedMotion ? "tag-chip--no-motion" : "", + ] + .filter(Boolean) + .join(" "); + }; + + /** + * Determine whether a chip is currently the active filter. + * Comparison is case-insensitive to match the MarketplacePage behaviour. + */ + const isActive = (tag: string): boolean => + activeTag !== null && activeTag.toLowerCase() === tag.toLowerCase(); + + /** + * Toggle: clicking the active tag deselects it (passes `null`), clicking + * any other tag selects it. + */ + const handleClick = (tag: string) => { + onTagClick(isActive(tag) ? null : tag); + }; + + return ( + /* + * role="group" + aria-label gives screen readers a semantic boundary + * around the set of filter controls without using a landmark element. + */ +
+ {tags.map((tag) => ( + + ))} import { useMemo } from "react"; import MOCK_APIS from "../data/mockApis"; import Skeleton from "../components/Skeleton";