From 3492b80c3cfef9c3f78b2a6bd79f26edf9ea64d5 Mon Sep 17 00:00:00 2001 From: pooramara Date: Tue, 20 Oct 2026 14:57:28 +0100 Subject: [PATCH] feat(frontend): keyboard nav, tests, WCAG focus trap for ApiHealthBadge & PaymentDetailsSheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #1551: enhance ApiHealthBadge keyboard navigation — Enter/R to refresh, Escape to blur, tooltip visible on focus, aria-label with shortcut hint - #1552: add comprehensive unit + snapshot tests for ApiHealthBadge covering all states, keyboard interactions, and ARIA attributes - #1553: implement WCAG 2.1 AA focus trap in PaymentDetailsSheet — role=dialog, aria-modal, aria-labelledby, roving tabindex tabs, focus restore on close - #1556: enhance PaymentDetailsSheet keyboard navigation — Arrow keys switch tabs, Home/End jump, Escape closes --- .../src/components/ApiHealthBadge.test.tsx | 186 ++++++++++++++++++ frontend/src/components/ApiHealthBadge.tsx | 50 +++-- .../src/components/PaymentDetailsSheet.tsx | 117 +++++++++-- 3 files changed, 325 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/ApiHealthBadge.test.tsx diff --git a/frontend/src/components/ApiHealthBadge.test.tsx b/frontend/src/components/ApiHealthBadge.test.tsx new file mode 100644 index 00000000..55e4a323 --- /dev/null +++ b/frontend/src/components/ApiHealthBadge.test.tsx @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ApiHealthBadge from './ApiHealthBadge'; + +// ── Mock fetch ─────────────────────────────────────────────────────────────── + +const mockFetch = vi.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +function healthyResponse() { + return { + ok: true, + json: () => Promise.resolve({ services: { database: 'ok', horizon: 'ok' } }), + }; +} + +function degradedResponse() { + return { + ok: true, + json: () => Promise.resolve({ services: { database: 'ok', horizon: 'down' } }), + }; +} + +function errorResponse() { + return Promise.reject(new Error('Network error')); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('ApiHealthBadge', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('renders loading state initially', () => { + mockFetch.mockReturnValue(new Promise(() => {})); // never resolves + render(); + expect(screen.getByText(/checking/i)).toBeInTheDocument(); + }); + + it('renders healthy state after successful fetch', async () => { + mockFetch.mockResolvedValue(healthyResponse()); + render(); + await waitFor(() => { + expect(screen.getByText(/api active/i)).toBeInTheDocument(); + }); + }); + + it('renders error state when fetch fails', async () => { + mockFetch.mockImplementation(() => errorResponse()); + render(); + await waitFor(() => { + expect(screen.getByText(/api down/i)).toBeInTheDocument(); + }); + }); + + it('renders degraded dependency message when a service is down', async () => { + mockFetch.mockResolvedValue(degradedResponse()); + render(); + await waitFor(() => { + expect(screen.getByText(/api active/i)).toBeInTheDocument(); + }); + // Tooltip shows degraded dependency after hover/focus + const button = screen.getByRole('button'); + fireEvent.focus(button); + await waitFor(() => { + expect(screen.getByText(/degraded dependency/i)).toBeInTheDocument(); + }); + }); + + it('has accessible aria-label with status and shortcut hint', async () => { + mockFetch.mockResolvedValue(healthyResponse()); + render(); + await waitFor(() => { + expect(screen.getByText(/api active/i)).toBeInTheDocument(); + }); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-label', expect.stringContaining('Press Enter or R')); + }); + + it('has aria-live="polite" for screen reader announcements', () => { + mockFetch.mockReturnValue(new Promise(() => {})); + render(); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-live', 'polite'); + }); + + it('triggers refresh on Enter key', async () => { + mockFetch.mockResolvedValue(healthyResponse()); + render(); + await waitFor(() => { + expect(screen.getByText(/api active/i)).toBeInTheDocument(); + }); + mockFetch.mockClear(); + mockFetch.mockResolvedValue(healthyResponse()); + const button = screen.getByRole('button'); + fireEvent.keyDown(button, { key: 'Enter' }); + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + }); + + it('triggers refresh on "r" key', async () => { + mockFetch.mockResolvedValue(healthyResponse()); + render(); + await waitFor(() => { + expect(screen.getByText(/api active/i)).toBeInTheDocument(); + }); + mockFetch.mockClear(); + mockFetch.mockResolvedValue(healthyResponse()); + const button = screen.getByRole('button'); + fireEvent.keyDown(button, { key: 'r' }); + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + }); + + it('blurs button on Escape key', async () => { + mockFetch.mockResolvedValue(healthyResponse()); + render(); + await waitFor(() => { + expect(screen.getByText(/api active/i)).toBeInTheDocument(); + }); + const button = screen.getByRole('button'); + button.focus(); + expect(document.activeElement).toBe(button); + fireEvent.keyDown(button, { key: 'Escape' }); + expect(document.activeElement).not.toBe(button); + }); + + it('shows tooltip on keyboard focus', async () => { + mockFetch.mockResolvedValue(healthyResponse()); + render(); + await waitFor(() => { + expect(screen.getByText(/api active/i)).toBeInTheDocument(); + }); + const button = screen.getByRole('button'); + fireEvent.focus(button); + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveClass('opacity-100'); + }); + + it('hides tooltip on blur', async () => { + mockFetch.mockResolvedValue(healthyResponse()); + render(); + await waitFor(() => { + expect(screen.getByText(/api active/i)).toBeInTheDocument(); + }); + const button = screen.getByRole('button'); + fireEvent.focus(button); + fireEvent.blur(button); + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveClass('opacity-0'); + }); + + it('matches snapshot in healthy state', async () => { + mockFetch.mockResolvedValue(healthyResponse()); + const { container } = render(); + await waitFor(() => { + expect(screen.getByText(/api active/i)).toBeInTheDocument(); + }); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('matches snapshot in error state', async () => { + mockFetch.mockImplementation(() => errorResponse()); + const { container } = render(); + await waitFor(() => { + expect(screen.getByText(/api down/i)).toBeInTheDocument(); + }); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('matches snapshot in loading state', () => { + mockFetch.mockReturnValue(new Promise(() => {})); + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/components/ApiHealthBadge.tsx b/frontend/src/components/ApiHealthBadge.tsx index 019de27c..ae31c862 100644 --- a/frontend/src/components/ApiHealthBadge.tsx +++ b/frontend/src/components/ApiHealthBadge.tsx @@ -1,12 +1,14 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; type ExtendedHealthStatus = "loading" | "healthy" | "error"; export default function ApiHealthBadge() { const [status, setStatus] = useState("loading"); const [errorMsg, setErrorMsg] = useState(null); + const [tooltipVisible, setTooltipVisible] = useState(false); + const buttonRef = useRef(null); const checkHealth = async () => { try { @@ -17,7 +19,7 @@ export default function ApiHealthBadge() { const services = data?.services ?? {}; const dbOk = services.database === "ok"; const horizonOk = services.horizon === "ok"; - const apiReachable = true; // If we got an HTTP response, backend is reachable. + const apiReachable = true; const healthy = apiReachable; if (healthy) { @@ -42,26 +44,37 @@ export default function ApiHealthBadge() { useEffect(() => { checkHealth(); - // Re-check every 60 seconds const interval = setInterval(checkHealth, 60000); return () => { clearInterval(interval); }; }, []); - const handleRefresh = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleRefresh = useCallback(async () => { if (status === "loading") return; - - // Optimistic Update: Immediately transition to visual checking/loading state setStatus("loading"); setErrorMsg(null); - - // satisfy with a micro-delay so the animation feels responsive and interactive await new Promise((resolve) => setTimeout(resolve, 400)); await checkHealth(); - }; + }, [status]); + + // #1551 — keyboard: Enter/Space activate the button (native), Escape blurs, + // and "r" triggers a manual re-check when focused. Tooltip is toggled on + // focus so keyboard users can read it without hovering. + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setTooltipVisible(false); + buttonRef.current?.blur(); + return; + } + if (e.key === "r" || e.key === "R") { + e.preventDefault(); + void handleRefresh(); + } + }, + [handleRefresh], + ); const config = { loading: { @@ -86,12 +99,16 @@ export default function ApiHealthBadge() { return ( diff --git a/frontend/src/components/PaymentDetailsSheet.tsx b/frontend/src/components/PaymentDetailsSheet.tsx index 21868c5e..3ddc9466 100644 --- a/frontend/src/components/PaymentDetailsSheet.tsx +++ b/frontend/src/components/PaymentDetailsSheet.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState, useCallback } from "react"; import { useLocale } from "next-intl"; import { AnimatePresence, motion } from "framer-motion"; import { useMerchantApiKey } from "@/lib/merchant-store"; @@ -44,6 +44,10 @@ export default function PaymentDetailsSheet({ paymentId, isOpen, onClose }: Paym const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState<"details" | "metadata" | "webhooks">("details"); + // #1553 — refs for focus trap and initial focus management + const sheetRef = useRef(null); + const closeButtonRef = useRef(null); + const tabRefs = useRef>(new Map()); useEffect(() => { if (!isOpen || !paymentId || !apiKey) return; @@ -65,6 +69,81 @@ export default function PaymentDetailsSheet({ paymentId, isOpen, onClose }: Paym load(); }, [paymentId, isOpen, apiKey]); + // #1553 — WCAG 2.1 AA focus trap: keep Tab/Shift+Tab cycling inside the sheet, + // move initial focus to the close button on open, and restore focus on close. + const previousFocusRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + previousFocusRef.current = document.activeElement as HTMLElement | null; + // Focus the close button once the sheet mounts + const timer = setTimeout(() => closeButtonRef.current?.focus(), 50); + return () => { + clearTimeout(timer); + previousFocusRef.current?.focus(); + }; + }, [isOpen]); + + // #1553 — trap Tab within the sheet + const trapFocus = useCallback((e: React.KeyboardEvent) => { + if (e.key !== "Tab" || !sheetRef.current) return; + const focusables = sheetRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }, []); + + // #1556 — keyboard: Escape closes, Arrow Left/Right switch tabs, + // Home/End jump to first/last tab. + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + trapFocus(e); + const tabs = ["details", "metadata", "webhooks"] as const; + const idx = tabs.indexOf(activeTab); + + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + if (e.key === "ArrowRight") { + e.preventDefault(); + const next = tabs[(idx + 1) % tabs.length]; + setActiveTab(next); + tabRefs.current.get(next)?.focus(); + return; + } + if (e.key === "ArrowLeft") { + e.preventDefault(); + const prev = tabs[(idx - 1 + tabs.length) % tabs.length]; + setActiveTab(prev); + tabRefs.current.get(prev)?.focus(); + return; + } + if (e.key === "Home") { + e.preventDefault(); + setActiveTab(tabs[0]); + tabRefs.current.get(tabs[0])?.focus(); + return; + } + if (e.key === "End") { + e.preventDefault(); + setActiveTab(tabs[tabs.length - 1]); + tabRefs.current.get(tabs[tabs.length - 1])?.focus(); + } + }, + [activeTab, onClose, trapFocus], + ); + useEffect(() => { const onEsc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onEsc); @@ -76,9 +155,14 @@ export default function PaymentDetailsSheet({ paymentId, isOpen, onClose }: Paym {isOpen && ( <> + onClick={onClose} className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm" aria-hidden="true" />
-

Payment Details

+

Payment Details

{paymentId}
-