diff --git a/frontend/src/components/WebhookDetailModal.test.tsx b/frontend/src/components/WebhookDetailModal.test.tsx new file mode 100644 index 0000000..77960be --- /dev/null +++ b/frontend/src/components/WebhookDetailModal.test.tsx @@ -0,0 +1,182 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import WebhookDetailModal, { + DISMISS_DISTANCE_PX, + NAVIGATE_DISTANCE_PX, + resolveSwipe, + WebhookLog, +} from "./WebhookDetailModal"; + +vi.mock("./CopyButton", () => ({ + __esModule: true, + default: () => null, +})); + +vi.mock("@/components/ui/Button", () => ({ + __esModule: true, + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})); + +const LOG: WebhookLog = { + id: "log-1", + payment_id: "p1", + status_code: 500, + event: "payment.failed", + url: "https://example.com/wh", + request_payload: { amount: "10" }, + request_headers: null, + response_body: "error", + timestamp: "2026-04-25T10:00:00.000Z", +}; + +function swipe(el: Element, from: { x: number; y: number }, to: { x: number; y: number }) { + fireEvent.touchStart(el, { touches: [{ clientX: from.x, clientY: from.y }] }); + fireEvent.touchMove(el, { touches: [{ clientX: to.x, clientY: to.y }] }); + fireEvent.touchEnd(el, { changedTouches: [{ clientX: to.x, clientY: to.y }] }); +} + +describe("resolveSwipe", () => { + it("dismisses on a long downward drag", () => { + expect(resolveSwipe(0, DISMISS_DISTANCE_PX, 1000, "y")).toBe("dismiss"); + }); + + it("dismisses on a short but fast downward flick", () => { + expect(resolveSwipe(0, 40, 50, "y")).toBe("dismiss"); + }); + + it("does nothing on a short, slow downward drag", () => { + expect(resolveSwipe(0, 40, 1000, "y")).toBe("none"); + }); + + it("never dismisses on an upward drag", () => { + expect(resolveSwipe(0, -300, 50, "y")).toBe("none"); + }); + + it("maps horizontal swipes to next / previous", () => { + expect(resolveSwipe(-NAVIGATE_DISTANCE_PX, 0, 200, "x")).toBe("next"); + expect(resolveSwipe(NAVIGATE_DISTANCE_PX, 0, 200, "x")).toBe("previous"); + expect(resolveSwipe(-20, 0, 200, "x")).toBe("none"); + }); +}); + +describe("WebhookDetailModal drawer", () => { + const onClose = vi.fn(); + const onNext = vi.fn(); + const onPrevious = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + document.body.style.overflow = ""; + }); + + function renderDrawer(props: Partial> = {}) { + return render( + , + ); + } + + it("renders an accessible dialog with a mobile drag handle", () => { + renderDrawer(); + const dialog = screen.getByRole("dialog", { name: /webhook delivery detail/i }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveFocus(); + expect(screen.getByTestId("webhook-drawer-handle")).toHaveAttribute("aria-hidden", "true"); + expect(screen.getByText("Delivery 2 of 3")).toBeInTheDocument(); + }); + + it("locks body scroll while open and restores it on close", () => { + const { rerender } = renderDrawer(); + expect(document.body.style.overflow).toBe("hidden"); + rerender(); + expect(document.body.style.overflow).toBe(""); + }); + + it("swiping down past the threshold dismisses the drawer", () => { + renderDrawer(); + swipe(screen.getByTestId("webhook-drawer-handle"), { x: 100, y: 100 }, { x: 100, y: 100 + DISMISS_DISTANCE_PX + 10 }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("follows the finger while dragging and snaps back on a short drag", () => { + renderDrawer(); + const dialog = screen.getByTestId("webhook-detail-drawer"); + const handle = screen.getByTestId("webhook-drawer-handle"); + + fireEvent.touchStart(handle, { touches: [{ clientX: 100, clientY: 100 }] }); + fireEvent.touchMove(handle, { touches: [{ clientX: 100, clientY: 140 }] }); + expect(dialog.style.transform).toContain("40px"); + expect(dialog).toHaveAttribute("data-dragging", "true"); + + // Slow release well below the threshold + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 2000); + fireEvent.touchEnd(handle, { changedTouches: [{ clientX: 100, clientY: 140 }] }); + nowSpy.mockRestore(); + + expect(onClose).not.toHaveBeenCalled(); + expect(dialog.style.transform).toBe(""); + expect(dialog).not.toHaveAttribute("data-dragging"); + }); + + it("does not dismiss when dragging down inside scrolled content", () => { + renderDrawer(); + const body = screen.getByTestId("webhook-drawer-body"); + body.scrollTop = 200; + swipe(body, { x: 100, y: 100 }, { x: 100, y: 400 }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("swiping left / right navigates between deliveries", () => { + renderDrawer(); + const handle = screen.getByTestId("webhook-drawer-handle"); + swipe(handle, { x: 200, y: 100 }, { x: 200 - NAVIGATE_DISTANCE_PX - 10, y: 105 }); + expect(onNext).toHaveBeenCalledTimes(1); + swipe(handle, { x: 100, y: 100 }, { x: 100 + NAVIGATE_DISTANCE_PX + 10, y: 105 }); + expect(onPrevious).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("sideways swipes inside the payload scroll the code instead of navigating", () => { + renderDrawer(); + const pre = document.querySelector("pre")!; + swipe(pre, { x: 200, y: 100 }, { x: 50, y: 100 }); + expect(onNext).not.toHaveBeenCalled(); + }); + + it("offers button and keyboard alternatives to every gesture", () => { + renderDrawer(); + fireEvent.click(screen.getByRole("button", { name: "Next delivery" })); + fireEvent.click(screen.getByRole("button", { name: "Previous delivery" })); + fireEvent.keyDown(document, { key: "ArrowRight" }); + fireEvent.keyDown(document, { key: "ArrowLeft" }); + fireEvent.keyDown(document, { key: "Escape" }); + + expect(onNext).toHaveBeenCalledTimes(2); + expect(onPrevious).toHaveBeenCalledTimes(2); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("disables navigation buttons at the ends of the list", () => { + renderDrawer({ onPrevious: undefined }); + expect(screen.getByRole("button", { name: "Previous delivery" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next delivery" })).toBeEnabled(); + }); + + it("closes when the backdrop is tapped", () => { + renderDrawer(); + fireEvent.click(screen.getByTestId("modal-backdrop")); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/WebhookDetailModal.tsx b/frontend/src/components/WebhookDetailModal.tsx index e69d14c..6646299 100644 --- a/frontend/src/components/WebhookDetailModal.tsx +++ b/frontend/src/components/WebhookDetailModal.tsx @@ -1,7 +1,22 @@ "use client"; -import React from "react"; -import { Modal } from "@/components/ui/Modal"; +/** + * WebhookDetailModal + * + * Shows a single webhook delivery. Renders as a bottom drawer on mobile and a + * centered dialog from the `sm` breakpoint up. + * + * Touch gestures (mobile drawer): + * - Swipe down to dismiss. The drawer follows the finger and snaps back when + * released before the dismiss threshold. Dragging from the scrollable body + * only starts once it is scrolled to the top, so normal scrolling still works. + * - Swipe left / right to move to the next / previous delivery. + * + * Every gesture has a non-gesture equivalent (WCAG 2.5.1): the close button, + * Escape, the previous / next buttons and the ArrowLeft / ArrowRight keys. + */ + +import React, { useCallback, useEffect, useId, useRef, useState } from "react"; import { Button } from "@/components/ui/Button"; import CopyButton from "./CopyButton"; @@ -21,6 +36,49 @@ interface WebhookDetailModalProps { log: WebhookLog | null; isOpen: boolean; onClose: () => void; + /** Show the previous delivery. Omit when there is none. */ + onPrevious?: () => void; + /** Show the next delivery. Omit when there is none. */ + onNext?: () => void; + /** Human-readable position, e.g. "3 of 12". */ + positionLabel?: string; +} + +/** Minimum downward drag (px) that dismisses the drawer. */ +export const DISMISS_DISTANCE_PX = 120; +/** Minimum downward speed (px/ms) that dismisses the drawer on a quick flick. */ +export const DISMISS_VELOCITY = 0.5; +/** Minimum horizontal travel (px) that switches to the next / previous log. */ +export const NAVIGATE_DISTANCE_PX = 60; +/** Movement (px) before a touch is locked to one axis. */ +const AXIS_LOCK_PX = 10; + +export type SwipeAction = "dismiss" | "next" | "previous" | "none"; + +/** + * Decides what a completed swipe should do. + * + * @param dx horizontal travel in px (positive = right) + * @param dy vertical travel in px (positive = down) + * @param durationMs time from touchstart to touchend + * @param axis axis the gesture was locked to, if any + */ +export function resolveSwipe( + dx: number, + dy: number, + durationMs: number, + axis: "x" | "y" | null, +): SwipeAction { + if (axis === "y" && dy > 0) { + const velocity = dy / Math.max(durationMs, 1); + if (dy >= DISMISS_DISTANCE_PX || velocity >= DISMISS_VELOCITY) { + return "dismiss"; + } + } + if (axis === "x" && Math.abs(dx) >= NAVIGATE_DISTANCE_PX) { + return dx < 0 ? "next" : "previous"; + } + return "none"; } function statusClasses(statusCode: number) { @@ -33,11 +91,36 @@ function statusClasses(statusCode: number) { return "bg-yellow-500/20 text-yellow-300 border border-yellow-500/40"; } +const FOCUSABLE = + 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +interface TouchState { + x: number; + y: number; + time: number; + axis: "x" | "y" | null; + /** Whether a downward drag may move the drawer (false while body is scrolled). */ + canDismiss: boolean; + /** Whether a sideways swipe may navigate (false inside horizontally scrollable code). */ + canNavigate: boolean; +} + export default function WebhookDetailModal({ log, isOpen, onClose, + onPrevious, + onNext, + positionLabel, }: WebhookDetailModalProps) { + const titleId = useId(); + const dialogRef = useRef(null); + const bodyRef = useRef(null); + const touch = useRef(null); + const [dragY, setDragY] = useState(0); + const [dragX, setDragX] = useState(0); + const [dragging, setDragging] = useState(false); + const generateCurl = (log: WebhookLog) => { const headers = log.request_headers || {}; const payload = JSON.stringify(log.request_payload || {}, null, 2); @@ -50,73 +133,314 @@ export default function WebhookDetailModal({ return curl; }; + const resetDrag = useCallback(() => { + touch.current = null; + setDragging(false); + setDragY(0); + setDragX(0); + }, []); + + // Keyboard: Escape closes, arrows navigate, Tab is trapped in the dialog. + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + if (event.key === "ArrowLeft" && onPrevious) { + event.preventDefault(); + onPrevious(); + return; + } + if (event.key === "ArrowRight" && onNext) { + event.preventDefault(); + onNext(); + return; + } + if (event.key !== "Tab" || !dialogRef.current) return; + + const focusable = Array.from( + dialogRef.current.querySelectorAll(FOCUSABLE), + ); + if (focusable.length === 0) { + event.preventDefault(); + dialogRef.current.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose, onPrevious, onNext]); + + // Focus the dialog on open, restore focus and body scroll on close. + useEffect(() => { + if (!isOpen) return; + + const previousActiveElement = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + dialogRef.current?.focus(); + + return () => { + document.body.style.overflow = originalOverflow; + previousActiveElement?.focus(); + }; + }, [isOpen]); + + // Start each log at the top with no leftover drag offset. + useEffect(() => { + resetDrag(); + bodyRef.current?.scrollTo?.({ top: 0 }); + }, [log?.id, resetDrag]); + + const handleTouchStart = (event: React.TouchEvent) => { + const point = event.touches[0]; + const target = event.target as Element; + const inBody = bodyRef.current?.contains(target) ?? false; + touch.current = { + x: point.clientX, + y: point.clientY, + time: Date.now(), + axis: null, + canDismiss: !inBody || (bodyRef.current?.scrollTop ?? 0) <= 0, + canNavigate: !target.closest?.("pre, code"), + }; + }; + + const handleTouchMove = (event: React.TouchEvent) => { + const state = touch.current; + if (!state) return; + + const point = event.touches[0]; + const dx = point.clientX - state.x; + const dy = point.clientY - state.y; + + if (!state.axis) { + if (Math.abs(dx) < AXIS_LOCK_PX && Math.abs(dy) < AXIS_LOCK_PX) return; + state.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; + } + + if (state.axis === "y" && state.canDismiss && dy > 0) { + setDragging(true); + setDragY(dy); + } else if (state.axis === "x" && state.canNavigate && (dx < 0 ? onNext : onPrevious)) { + setDragging(true); + // Damped so the drawer only nudges sideways as a hint. + setDragX(dx * 0.35); + } + }; + + const handleTouchEnd = (event: React.TouchEvent) => { + const state = touch.current; + if (!state) return; + + const point = event.changedTouches[0]; + const dx = point.clientX - state.x; + const dy = point.clientY - state.y; + const action = resolveSwipe( + state.canNavigate ? dx : 0, + state.canDismiss ? dy : 0, + Date.now() - state.time, + state.axis, + ); + + resetDrag(); + + if (action === "dismiss") onClose(); + else if (action === "next") onNext?.(); + else if (action === "previous") onPrevious?.(); + }; + + if (!isOpen) return null; + + const navButtonClasses = + "inline-flex h-11 w-11 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-mint disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent sm:h-9 sm:w-9"; + return ( - - {log && ( -
-
-
-

Event

-

{log.event || "—"}

-
-
-

Status

- - HTTP {log.status_code} - -
-
+
+
0 ? Math.max(0.3, 1 - dragY / 400) : undefined }} + onClick={onClose} + data-testid="modal-backdrop" + aria-hidden="true" + /> -
-

Endpoint

- - {log.url} - +
+ {/* Drag handle: the primary swipe target on mobile */} +