Skip to content
Open
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
87 changes: 66 additions & 21 deletions docs/uiux/undo-banner-pattern.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ Use a confirmation dialog instead when an action is **irreversible** or
- **Countdown ring** — depletes over the reversible window (default 5s, auto-scaled for bursts),
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.
- **Aggregate "Undo all" Header** — shown when multiple actions (`>1`) are pending; reverses all stacked actions in reverse chronological order.
- **Undo CTA** — primary action; reverses the change and removes the banner (`aria-label="Undo: <message>"`).
- **Dismiss (✕)** — commits the action immediately and removes the banner (`aria-label="Dismiss: <message>"`).
- **Aggregate "Undo all" Header** — shown when multiple actions (`>1`) are pending; reverses all stacked actions in reverse chronological order (`aria-label="Undo all N pending actions"`).
- **Overflow summary** — displayed when pending banners exceed `maxVisible` (default 4). Displays `+N more pending` visually and includes hidden screen-reader text `"N additional undoable actions"` with `role="status"` and `aria-label`.

## Action contract (for engineers)

Expand Down Expand Up @@ -92,10 +93,35 @@ pure render of the current stack.
Beyond `maxVisible` (defaults to **4**) older banners collapse into a `+N more pending`
summary rather than overflowing the viewport.
- **Undo all affordance** — when 2 or more actions are active, an aggregate header appears offering
an **Undo all** button to bulk-revert all actions and a **Dismiss all (✕)** control to commit all.
an **Undo all** button to bulk-revert all actions and a **Dismiss all (✕)** control to commit all. It is hidden or absent when only a single action exists to reduce UI noise.
- **Independent lifecycles** — each banner maintains its own timer; expiring or
undoing one item preserves remaining items in the stack.

## Stack limits & overflow behavior

To prevent notification clutter and viewport overflow:
- The stack displays up to **4 visible items** by default (`maxVisible = 4`).
- Additional actions beyond 4 cause older banners to collapse into an overflow indicator showing `+N more pending` (e.g. `+3 more pending`).
- Screen reader accessibility: The overflow element features `role="status"`, `aria-label="${hiddenCount} additional undoable actions"`, and visually-hidden text `<span className="sr-only">${hiddenCount} additional undoable actions</span>` so assistive technologies announce the overflow count without confusing screen reader users.

## Undo All aggregate action rules & failure handling

The aggregate **Undo all** action processes every active item currently in the stack:

### Execution contract
- Items are undone in **reverse chronological order** (newest to oldest).
- All registered `onUndo` callbacks are invoked and their respective banners are immediately removed from the stack.

### Expected behavior under edge cases:
- **One undo fails (Network / API failure)**:
If an individual `onUndo` callback throws or fails (e.g., network timeout during server rollback), the frontend catches the error, retains or notifies the user via an error toast, and continues processing remaining items in the stack.
- **Some actions already expired**:
Expired items auto-commit via their individual timers and leave the stack before `undoAll` is clicked. `undoAll` only operates on currently active items.
- **Multiple action types exist**:
The stack seamlessly handles mixed actions (e.g., deleted draft, archived folder, removed member, renamed workspace). `undoAll` executes each action's specific `onUndo` handler in sequence.
- **Partial undos**:
If a user manually undos 1 item and then clicks "Undo all", "Undo all" undoes the remaining active items in the stack.

## Auto-dismiss timing per stack size

To prevent rapid action bursts from overwhelming users before they can react, auto-dismiss durations scale dynamically with stack size when explicit `durationMs` is omitted:
Expand All @@ -108,13 +134,21 @@ To prevent rapid action bursts from overwhelming users before they can react, au
| 4 items | 8.0s (8000ms) |
| 5+ items | 9.0s - 10.0s max (10000ms cap) |

### Timer reset & lifecycle behavior
- **New item addition**: When a new item is added to an active stack, its timer starts with the scaled duration for that stack size. Existing timers continue uninterrupted, maintaining independent lifecycles.
- **Timer expiry**: When an individual item's timer reaches 0, its `onCommit` callback runs, the banner disappears, and the stack smoothly shrinks.

## 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.
| Device | Stack Placement | Width & Spacing | Touch Target Sizing | Overflow Handling |
| --- | --- | --- | --- | --- |
| **Desktop** | Pinned bottom-center (`bottom-16`), `z-50` | `w-full max-w-md` | Standard button padding (`px-2.5 py-2`) | Max 4 visible items; `+N more pending` overflow |
| **Tablet** | Pinned bottom-center (`bottom-16`), `z-50` | `w-full max-w-md` | Tap targets `min-h-[44px]` | Max 4 visible items; `+N more pending` overflow |
| **Mobile** | Pinned bottom-center (`bottom-16`), `z-50` | `w-full max-w-md`, full-width inside `px-4` margin | Enforced `min-h-[44px]` & `min-w-[44px]` touch targets | Max 4 visible items; message text truncates (`truncate`) |

- Banners use `w-full max-w-md`: full width with side padding on small screens, capped to a comfortable card width on larger screens.
- Mobile stack caps at `maxVisible = 4` to prevent vertical viewport clipping on smaller devices while leaving interactive controls easily tapable.
- The layout is a single flex row; the message truncates (`truncate`) so the
Undo and dismiss controls always remain reachable.
- The layout is a single flex row; the message truncates (`truncate`) so the Undo and dismiss controls always remain reachable.

## Keyboard shortcut: Cmd/Ctrl+Z (Issue #279)

Expand Down Expand Up @@ -173,10 +207,16 @@ const [isModalOpen, setIsModalOpen] = useState(false);
- **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.
- **Screen reader announcement ordering** — Stack changes announce politely in order of arrival.
- **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.)
labelled **Undo** button.
- **Accessible button labels** — Buttons feature explicit descriptive labels:
- Individual Undo CTA: `aria-label="Undo: <message>"`
- Individual Dismiss CTA: `aria-label="Dismiss: <message>"`
- Aggregate Undo All CTA: `aria-label="Undo all N pending actions"`
- Overflow indicator: `aria-label="N additional undoable actions"`
- **Touch targets** — Interactive buttons satisfy WCAG 2.1 AA Target Size (2.5.5 / 2.5.8) with `min-h-[44px]` tap target bounds on mobile/tablet viewports.
- **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).
Expand All @@ -185,30 +225,35 @@ const [isModalOpen, setIsModalOpen] = useState(false);
**`Cmd/Ctrl+Z`** shortcut provides a power-user path to undo the newest
banner without reaching for the mouse. After undo, focus returns to the
element that initiated the action.
- **Dismiss labelling** — the ✕ control has an explicit
`aria-label="Dismiss: <message>"` so its purpose is unambiguous out of context.

### axe notes

`UndoBanner.test.tsx` runs `jest-axe` against single banners as well as full stacked layouts with "Undo all" headers and asserts
**no violations**. Points verified during design:
`UndoBanner.test.tsx` runs `jest-axe` against single banners as well as full stacked layouts with "Undo all" headers and overflow indicators, asserting **no violations**. Points verified during design:

- Contrast: white text and the `#60a5fa` Undo CTA on the `#1f2937` banner surface meet AA contrast for normal text.
- Decorative SVG ring carries `aria-hidden="true"` and no role, so it is not announced.
- All interactive elements expose explicit accessible names (button text and `aria-label`).

- Contrast: white text and the `#60a5fa` Undo CTA on the `#1f2937` banner
surface meet AA contrast for normal text.
- Decorative SVG ring carries `aria-hidden="true"` and no role, so it is not
announced.
- All interactive elements expose an accessible name (button text or
`aria-label`).
## Edge Cases

- **Extremely rapid action bursts**: When users perform 5-10 actions in a single second, the stack displays the 4 newest actions, while collapse summary updates to `+N more pending` with an accessible announcement. Duration auto-scales up to 10s.
- **Mixed action types**: The stack smoothly mixes different action descriptions (e.g. deleted project, archived folder, removed member) and handles their independent undo handlers cleanly.
- **Expired undo actions**: Each banner timer operates independently. Expired banners commit and auto-dismiss without affecting adjacent active banners.
- **Stack shrinking as items expire**: When older items expire or are dismissed, lower items remain in position and overflow count decrements smoothly.
- **Undo all after partial individual undos**: Reverses all currently remaining pending actions without error.
- **Offline / network failures**: Errors thrown inside custom `onUndo` callbacks are caught gracefully, allowing remaining actions to complete and displaying a fallback toast if needed.
- **Reduced motion preference**: Automatically replaces SVG ring animations with static whole-second text numbers.

## Test coverage

- [`UndoBanner.test.tsx`](../../src/components/UndoBanner/UndoBanner.test.tsx) —
rendering, Undo/dismiss callbacks, `onUndoAll`/`onDismissAll` aggregate actions, custom labels, newest-on-top stacking,
`+N more` overflow (max 4 default), decorative ring, reduced-motion fallback, keyboard shortcut integration, and axe assertions.
`+N more` overflow (max 4 default), decorative ring, reduced-motion fallback, keyboard shortcut integration, accessibility labels, overflow announcements, and axe assertions.
- [`useUndoBanners.test.tsx`](../../src/hooks/useUndoBanners.test.tsx) —
registration, countdown→commit, undo (with no late commit), dismiss→commit,
`undoAll()`, `dismissAll()`, dynamic auto-dismiss scaling by stack size, and independent stacked lifecycles.
- [`useUndoKeyboard.test.tsx`](../../src/hooks/useUndoKeyboard.test.tsx) —
Cmd/Ctrl+Z triggers undo on newest banner, suppressed in editable elements,
focus-return to origin element, no-op when no banners are visible, and
cleanup on unmount.

47 changes: 47 additions & 0 deletions src/components/UndoBanner/UndoBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,4 +235,51 @@ describe("UndoBanner", () => {
expect(onKeyboardUndo).toHaveBeenCalledWith("kb1");
expect(onUndo).not.toHaveBeenCalled();
});

it("provides accessible aria-labels for individual Undo and Undo all buttons", () => {
const banners = [
makeBanner({ id: "b1", message: "Deleted Project A", actionLabel: "Undo" }),
makeBanner({ id: "b2", message: "Archived Folder", actionLabel: "Undo" }),
];
render(
<UndoBanner
banners={banners}
onUndo={vi.fn()}
onDismiss={vi.fn()}
onUndoAll={vi.fn()}
onDismissAll={vi.fn()}
/>,
);

expect(screen.getByRole("button", { name: "Undo: Deleted Project A" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Undo: Archived Folder" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Undo all 2 pending actions" })).toBeInTheDocument();
});

it("provides screen-reader accessible overflow announcement", () => {
const banners = Array.from({ length: 7 }, (_, i) =>
makeBanner({ id: `b${i}`, message: `Action ${i}` }),
);
render(<UndoBanner banners={banners} onUndo={vi.fn()} onDismiss={vi.fn()} />);

const overflow = screen.getByTestId("undo-overflow");
expect(overflow).toHaveAttribute("aria-label", "3 additional undoable actions");
expect(screen.getByText("3 additional undoable actions")).toHaveClass("sr-only");
});

it("passes axe checks when overflow and header are present", async () => {
const banners = Array.from({ length: 6 }, (_, i) =>
makeBanner({ id: `b${i}`, message: `Action ${i}` }),
);
const { container } = render(
<UndoBanner
banners={banners}
onUndo={vi.fn()}
onDismiss={vi.fn()}
onUndoAll={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
});
17 changes: 11 additions & 6 deletions src/components/UndoBanner/UndoBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ export const UndoBanner: React.FC<UndoBannerProps> = ({
<button
type="button"
onClick={onUndoAll}
aria-label={`Undo all ${banners.length} pending actions`}
data-testid="undo-all-button"
className="flex items-center gap-1 rounded px-2 py-1 font-semibold text-[#60a5fa] hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-[#60a5fa]"
className="flex min-h-[36px] items-center gap-1 rounded px-2.5 py-1 font-semibold text-[#60a5fa] hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-[#60a5fa]"
>
<Undo2 size={14} aria-hidden="true" />
Undo all
Expand All @@ -169,7 +170,7 @@ export const UndoBanner: React.FC<UndoBannerProps> = ({
onClick={onDismissAll}
aria-label="Dismiss all pending actions"
data-testid="dismiss-all-button"
className="rounded p-1 text-white/70 hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-white/50"
className="flex min-h-[36px] min-w-[36px] items-center justify-center rounded p-1.5 text-white/70 hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-white/50"
>
<X size={14} aria-hidden="true" />
</button>
Expand All @@ -193,7 +194,8 @@ export const UndoBanner: React.FC<UndoBannerProps> = ({
<button
type="button"
onClick={() => onUndo(banner.id)}
className="flex flex-shrink-0 items-center gap-1 rounded-md px-2 py-1 font-semibold text-[#60a5fa] hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-[#60a5fa]"
aria-label={`${banner.actionLabel}: ${banner.message}`}
className="flex min-h-[44px] flex-shrink-0 items-center gap-1 rounded-md px-2.5 py-2 font-semibold text-[#60a5fa] hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-[#60a5fa]"
>
<Undo2 size={16} aria-hidden="true" />
{banner.actionLabel}
Expand All @@ -202,7 +204,7 @@ export const UndoBanner: React.FC<UndoBannerProps> = ({
type="button"
onClick={() => onDismiss(banner.id)}
aria-label={`Dismiss: ${banner.message}`}
className="flex-shrink-0 rounded p-1 text-white/70 hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-white/50"
className="flex min-h-[44px] min-w-[44px] flex-shrink-0 items-center justify-center rounded p-2 text-white/70 hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-white/50"
>
<X size={16} aria-hidden="true" />
</button>
Expand All @@ -212,9 +214,12 @@ export const UndoBanner: React.FC<UndoBannerProps> = ({
{hiddenCount > 0 && (
<div
data-testid="undo-overflow"
className="pointer-events-auto rounded-md bg-[#111827] px-3 py-1 text-xs text-white/70"
role="status"
aria-label={`${hiddenCount} additional undoable actions`}
className="pointer-events-auto rounded-md bg-[#111827] px-3 py-1.5 text-xs text-white/70"
>
+{hiddenCount} more pending
<span aria-hidden="true">+{hiddenCount} more pending</span>
<span className="sr-only">{hiddenCount} additional undoable actions</span>
</div>
)}
</div>
Expand Down