From 85b3defc140adb89e1abef1da504650b41256c19 Mon Sep 17 00:00:00 2001 From: Taiwo Triumphant Date: Tue, 30 Jun 2026 00:26:15 +0100 Subject: [PATCH] design: undo banner pattern for reversible actions Add an accessible Undo banner pattern for reversible destructive actions (delete draft, remove from blacklist, archive offering): - UndoBanner component: a stack of banners pinned above the page footer with a countdown ring to permanence, a primary Undo CTA, and a dismiss control that commits immediately. Newest on top; banners beyond maxVisible collapse into a '+N more' summary. - useUndoBanners hook: owns the reversible-window timing, exposing registerUndo/undo/dismiss with onUndo and onCommit callbacks. - useReducedMotion hook: swaps the animated ring for a static seconds count under prefers-reduced-motion. Accessibility (WCAG 2.1 AA): polite live region, decorative aria-hidden countdown, labelled controls with visible focus rings, jest-axe assertion of no violations. Documented in docs/uiux/undo-banner-pattern.md with the engineer action contract, stacking/placement, responsive, and a11y notes. Co-Authored-By: Claude Opus 4.8 --- docs/uiux/undo-banner-pattern.md | 130 ++++ package-lock.json | 625 +++++++++++++++++- package.json | 2 + src/components/UndoBanner/UndoBanner.test.tsx | 137 ++++ src/components/UndoBanner/UndoBanner.tsx | 160 +++++ src/hooks/useReducedMotion.ts | 35 + src/hooks/useUndoBanners.test.tsx | 109 +++ src/hooks/useUndoBanners.ts | 163 +++++ 8 files changed, 1360 insertions(+), 1 deletion(-) create mode 100644 docs/uiux/undo-banner-pattern.md create mode 100644 src/components/UndoBanner/UndoBanner.test.tsx create mode 100644 src/components/UndoBanner/UndoBanner.tsx create mode 100644 src/hooks/useReducedMotion.ts create mode 100644 src/hooks/useUndoBanners.test.tsx create mode 100644 src/hooks/useUndoBanners.ts diff --git a/docs/uiux/undo-banner-pattern.md b/docs/uiux/undo-banner-pattern.md new file mode 100644 index 0000000..2d52a53 --- /dev/null +++ b/docs/uiux/undo-banner-pattern.md @@ -0,0 +1,130 @@ +# Undo Banner Pattern + +A consistent, accessible pattern for **reversible destructive actions** — +delete draft, remove from blacklist, archive offering. Instead of a blocking +"Are you sure?" confirmation, the action happens immediately and a banner offers +a short window to **Undo** before it becomes permanent. + +> Implementation: [`UndoBanner`](../../src/components/UndoBanner/UndoBanner.tsx), +> [`useUndoBanners`](../../src/hooks/useUndoBanners.ts), +> [`useReducedMotion`](../../src/hooks/useReducedMotion.ts). + +## Why this over a confirm dialog + +- **Lower friction** for frequent, low-stakes destructive actions. +- **Reversible by default** — the safety net is the Undo, not a modal gate. +- **Non-blocking** — the user keeps working; nothing steals focus. + +Use a confirmation dialog instead when an action is **irreversible** or +**high-impact** (e.g. deleting an account). Undo is for the *recoverable* cases. + +## Anatomy + +``` +┌─────────────────────────────────────────────┐ +│ ◷ Deleted "Q3 report" ↶ Undo ✕ │ +└─────────────────────────────────────────────┘ + │ │ │ │ + countdown message Undo CTA dismiss + ring (primary) (commit now) +``` + +- **Countdown ring** — depletes over the reversible window (default 5s), + signalling time-to-permanence. Decorative (`aria-hidden`). +- **Message** — past-tense description of what happened (`Deleted "Q3 report"`). +- **Undo CTA** — primary action; reverses the change and removes the banner. +- **Dismiss (✕)** — commits the action immediately and removes the banner. + +## Action contract (for engineers) + +Drive banners through `useUndoBanners`. Each reversible action provides: + +| Field | Required | Meaning | +| --- | --- | --- | +| `message` | yes | Past-tense summary shown in the banner. | +| `onUndo` | yes | Reverse the action (restore UI + cancel/rollback any persistence). | +| `onCommit` | no | Make the action permanent — runs when the timer elapses **or** the user dismisses. Omit if the action is already persisted and only `onUndo` changes state. | +| `actionLabel` | no | CTA label, defaults to `Undo`. | +| `durationMs` | no | Reversible window, defaults to `5000`. | + +```tsx +const { banners, registerUndo, undo, dismiss } = useUndoBanners(); + +function deleteDraft(draft: Draft) { + removeDraftFromList(draft.id); // optimistic UI update + registerUndo({ + message: `Deleted "${draft.title}"`, + onUndo: () => restoreDraftToList(draft), // reverse the optimistic update + onCommit: () => api.deleteDraft(draft.id) // persist only after the window + }); +} + +return ( + <> + {/* …page… */} + + +); +``` + +Timing is owned by the hook (a single shared ticker), so `` stays a +pure render of the current stack. + +## Placement & stacking + +- **Placement** — pinned via `position: fixed` to the bottom centre, **above the + page footer** (`bottom-16`), at `z-50`. The container is + `pointer-events-none` so it never blocks the page; each banner re-enables + pointer events for its own controls. +- **Stacking** — multiple banners stack vertically with the **newest on top**. + Beyond `maxVisible` (default 3) older banners collapse into a `+N more pending` + summary rather than overflowing the viewport. +- **Independent lifecycles** — each banner has its own countdown; expiring or + undoing one never affects the others. + +## Responsive behaviour + +- Banners are `w-full max-w-md`: full width with side padding on small screens, + capped to a comfortable card width on larger screens. +- The layout is a single flex row; the message truncates (`truncate`) so the + Undo and dismiss controls always remain reachable. + +## Accessibility (WCAG 2.1 AA) + +- **Polite live region** — the container is `role="status"` `aria-live="polite"` + `aria-atomic="false"`, so newly added banners are announced without + interrupting the user's current task. +- **Countdown is decorative** — the ring/seconds are `aria-hidden`. Screen-reader + users are not pressured by a ticking timer; they act through the clearly + labelled **Undo** button. (Consider pairing with a longer `durationMs` for + flows where assistive-tech users need more time.) +- **Reduced motion** — when `prefers-reduced-motion: reduce` is set, the animated + sweeping ring is replaced by a **static whole-second count** (no animation). + See [reduced-motion-guidelines.md](./reduced-motion-guidelines.md). +- **Keyboard & focus** — Undo and dismiss are native ` + + + ))} + + {hiddenCount > 0 && ( +
+ +{hiddenCount} more pending +
+ )} + + ); +}; diff --git a/src/hooks/useReducedMotion.ts b/src/hooks/useReducedMotion.ts new file mode 100644 index 0000000..d5e767f --- /dev/null +++ b/src/hooks/useReducedMotion.ts @@ -0,0 +1,35 @@ +/** + * useReducedMotion + * + * Returns `true` when the user has requested reduced motion via the OS-level + * `prefers-reduced-motion` setting. Components use this to swap animated + * affordances (e.g. a sweeping countdown ring) for a static equivalent. + */ + +import { useEffect, useState } from "react"; + +const QUERY = "(prefers-reduced-motion: reduce)"; + +export function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(() => { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia(QUERY).matches; + }); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mql = window.matchMedia(QUERY); + const onChange = (event: MediaQueryListEvent) => setReduced(event.matches); + + setReduced(mql.matches); + if (mql.addEventListener) { + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + } + // Safari < 14 fallback + mql.addListener(onChange); + return () => mql.removeListener(onChange); + }, []); + + return reduced; +} diff --git a/src/hooks/useUndoBanners.test.tsx b/src/hooks/useUndoBanners.test.tsx new file mode 100644 index 0000000..6360ba2 --- /dev/null +++ b/src/hooks/useUndoBanners.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useUndoBanners, DEFAULT_UNDO_DURATION_MS } from "./useUndoBanners"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("useUndoBanners", () => { + it("registers a banner with the default duration", () => { + const { result } = renderHook(() => useUndoBanners()); + + act(() => { + result.current.registerUndo({ message: "Deleted draft", onUndo: vi.fn() }); + }); + + expect(result.current.banners).toHaveLength(1); + expect(result.current.banners[0].message).toBe("Deleted draft"); + expect(result.current.banners[0].durationMs).toBe(DEFAULT_UNDO_DURATION_MS); + expect(result.current.banners[0].remainingMs).toBeGreaterThan(0); + }); + + it("counts down and commits the action when the timer elapses", () => { + const onUndo = vi.fn(); + const onCommit = vi.fn(); + const { result } = renderHook(() => useUndoBanners()); + + act(() => { + result.current.registerUndo({ message: "Archive offering", onUndo, onCommit, durationMs: 1000 }); + }); + + expect(result.current.banners).toHaveLength(1); + + act(() => { + vi.advanceTimersByTime(1100); + }); + + expect(result.current.banners).toHaveLength(0); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onUndo).not.toHaveBeenCalled(); + }); + + it("reverses the action and removes the banner on undo", () => { + const onUndo = vi.fn(); + const onCommit = vi.fn(); + const { result } = renderHook(() => useUndoBanners()); + + let id = ""; + act(() => { + id = result.current.registerUndo({ message: "Remove from blacklist", onUndo, onCommit }); + }); + + act(() => { + result.current.undo(id); + }); + + expect(result.current.banners).toHaveLength(0); + expect(onUndo).toHaveBeenCalledTimes(1); + expect(onCommit).not.toHaveBeenCalled(); + + // Timer must not fire a late commit for an undone action. + act(() => { + vi.advanceTimersByTime(DEFAULT_UNDO_DURATION_MS + 500); + }); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("commits immediately when dismissed", () => { + const onUndo = vi.fn(); + const onCommit = vi.fn(); + const { result } = renderHook(() => useUndoBanners()); + + let id = ""; + act(() => { + id = result.current.registerUndo({ message: "Delete draft", onUndo, onCommit }); + }); + + act(() => { + result.current.dismiss(id); + }); + + expect(result.current.banners).toHaveLength(0); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onUndo).not.toHaveBeenCalled(); + }); + + it("supports several stacked banners independently", () => { + const { result } = renderHook(() => useUndoBanners()); + + act(() => { + result.current.registerUndo({ message: "A", onUndo: vi.fn(), durationMs: 1000 }); + result.current.registerUndo({ message: "B", onUndo: vi.fn(), durationMs: 3000 }); + }); + + expect(result.current.banners).toHaveLength(2); + + // First banner expires; second remains. + act(() => { + vi.advanceTimersByTime(1200); + }); + + expect(result.current.banners).toHaveLength(1); + expect(result.current.banners[0].message).toBe("B"); + }); +}); diff --git a/src/hooks/useUndoBanners.ts b/src/hooks/useUndoBanners.ts new file mode 100644 index 0000000..874c094 --- /dev/null +++ b/src/hooks/useUndoBanners.ts @@ -0,0 +1,163 @@ +/** + * useUndoBanners + * + * State manager for the Undo banner pattern (Issue #162). It owns a stack of + * pending reversible actions, each with a countdown to permanence. When a + * countdown elapses (or the user dismisses the banner) the action is committed; + * if the user presses "Undo" the action is reversed instead. + * + * The hook is the single source of truth for timing so the presentational + * `` stays a pure render of the current stack. + * + * @example + * const { banners, registerUndo, undo, dismiss } = useUndoBanners(); + * + * function deleteDraft(draft) { + * removeFromUI(draft); + * registerUndo({ + * message: `Deleted "${draft.title}"`, + * onUndo: () => restoreToUI(draft), + * onCommit: () => api.deleteDraft(draft.id), + * }); + * } + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +/** Default time a destructive action remains reversible. */ +export const DEFAULT_UNDO_DURATION_MS = 5000; + +/** How often the countdown is recomputed. */ +const TICK_MS = 100; + +export interface RegisterUndoOptions { + /** Human-readable description of what happened, e.g. `Deleted "Q3 report"`. */ + message: string; + /** Reverse the action. Called when the user presses Undo. */ + onUndo: () => void; + /** + * Make the action permanent. Called when the countdown elapses or the user + * dismisses the banner. Optional — omit when the action is already persisted + * and Undo is the only thing that changes state. + */ + onCommit?: () => void; + /** Label for the Undo button. Defaults to "Undo". */ + actionLabel?: string; + /** How long the action stays reversible, in ms. Defaults to 5000. */ + durationMs?: number; +} + +export interface UndoBannerItem { + id: string; + message: string; + actionLabel: string; + durationMs: number; + /** Milliseconds remaining before the action becomes permanent. */ + remainingMs: number; +} + +interface InternalEntry extends UndoBannerItem { + expiresAt: number; + onUndo: () => void; + onCommit?: () => void; +} + +let counter = 0; +function nextId(): string { + counter += 1; + return `undo-${counter}-${Math.floor(performance.now())}`; +} + +export interface UseUndoBannersResult { + banners: UndoBannerItem[]; + /** Register a reversible action and show its banner. Returns the banner id. */ + registerUndo: (options: RegisterUndoOptions) => string; + /** Reverse the action and remove its banner. */ + undo: (id: string) => void; + /** Commit the action immediately and remove its banner. */ + dismiss: (id: string) => void; +} + +export function useUndoBanners(): UseUndoBannersResult { + const [banners, setBanners] = useState([]); + const entriesRef = useRef>(new Map()); + + const sync = useCallback(() => { + const now = performance.now(); + const visible: UndoBannerItem[] = []; + const expired: InternalEntry[] = []; + + for (const entry of entriesRef.current.values()) { + const remainingMs = Math.max(0, entry.expiresAt - now); + if (remainingMs <= 0) { + expired.push(entry); + } else { + visible.push({ + id: entry.id, + message: entry.message, + actionLabel: entry.actionLabel, + durationMs: entry.durationMs, + remainingMs, + }); + } + } + + // Commit any elapsed actions (outside of render). + for (const entry of expired) { + entriesRef.current.delete(entry.id); + entry.onCommit?.(); + } + + setBanners(visible); + }, []); + + // A single shared ticker keeps every countdown in step. + useEffect(() => { + const interval = window.setInterval(sync, TICK_MS); + return () => window.clearInterval(interval); + }, [sync]); + + const registerUndo = useCallback( + (options: RegisterUndoOptions): string => { + const id = nextId(); + const durationMs = options.durationMs ?? DEFAULT_UNDO_DURATION_MS; + entriesRef.current.set(id, { + id, + message: options.message, + actionLabel: options.actionLabel ?? "Undo", + durationMs, + remainingMs: durationMs, + expiresAt: performance.now() + durationMs, + onUndo: options.onUndo, + onCommit: options.onCommit, + }); + sync(); + return id; + }, + [sync], + ); + + const undo = useCallback( + (id: string) => { + const entry = entriesRef.current.get(id); + if (!entry) return; + entriesRef.current.delete(id); + entry.onUndo(); + sync(); + }, + [sync], + ); + + const dismiss = useCallback( + (id: string) => { + const entry = entriesRef.current.get(id); + if (!entry) return; + entriesRef.current.delete(id); + entry.onCommit?.(); + sync(); + }, + [sync], + ); + + return { banners, registerUndo, undo, dismiss }; +}