Skip to content
Draft
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
67 changes: 67 additions & 0 deletions frontend/src/components/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# UI Components — Conventions

## Success / confirmation feedback

### The canonical pattern: transient label swap

All success feedback in the UI uses a **~2 s transient label/icon swap**.
Use the shared `useTransientLabel` hook from `@/hooks/ui/useTransientLabel`:

```tsx
import { useTransientLabel } from "@/hooks/ui/useTransientLabel";
import { t } from "@lingui/core/macro";

function CopyButton({ text }: { text: string }) {
const {
isActive: isCopied,
trigger: triggerCopied,
srAnnouncement,
} = useTransientLabel({ announcement: t`Copied to clipboard` });

return (
<>
<button
onClick={async () => {
await navigator.clipboard.writeText(text);
triggerCopied();
}}
>
{isCopied ? "Copied!" : "Copy"}
</button>
{/* sr-only live region — include whenever the visible change is icon-only */}
<p role="status" className="sr-only">
{srAnnouncement}
</p>
</>
);
}
```

**Why not ad-hoc `useState` + `setTimeout`?**
The hook handles timer cleanup on unmount (no leak), restarts the timer on
rapid re-triggers, and optionally surfaces a `role="status"` announcement for
screen readers that would otherwise miss a silent icon swap.

**Default delay: 2000 ms.** Pass `{ delay: N }` to override.

### Accessibility

Supply the `announcement` option whenever the only visible change is an icon
swap (no text label change). For buttons that already change their text label
(e.g. "Copy" → "Copied!"), the announcement is optional but harmless.

Render `srAnnouncement` in a `role="status"` / `aria-live="polite"` sr-only
element. The live region is empty when inactive, so it only fires once on
trigger — not on every render.

---

## What NOT to use for success feedback

| Idiom | Use for |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Toasts** (`toast.success` / `<Toaster>`) | Auth/permission flows, session decisions ("You switched conversation", Graph sign-in prompts). **Not** for micro-actions like copy or form submission confirmations. |
| **Persistent resolved row** (removed 2026-07) | Was used by `ActionConfirmationCard`; removed because callers now close the card on resolution and show transient feedback on the trigger button instead. |

Errors continue to use **sticky toast** (`toast.error`) or **inline `<Alert
type="error">`** depending on scope (global vs. component-local).
9 changes: 5 additions & 4 deletions frontend/src/components/ui/Chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { memo, useCallback, useState } from "react";
import { InteractiveContainer } from "@/components/ui/Container/InteractiveContainer";
import { FilePreviewButton } from "@/components/ui/FileUpload/FilePreviewButton";
import { useImageLightbox } from "@/hooks/ui/useImageLightbox";
import { useTransientLabel } from "@/hooks/ui/useTransientLabel";
import { useGetFile } from "@/lib/generated/v1betaApi/v1betaApiComponents";
import {
useErrorReportFeature,
Expand Down Expand Up @@ -119,7 +120,8 @@ export const ChatMessage = memo(function ChatMessage({

// Local state for raw markdown toggle
const [showRawMarkdown, setShowRawMarkdown] = useState(false);
const [isErrorReportCopied, setIsErrorReportCopied] = useState(false);
const { isActive: isErrorReportCopied, trigger: triggerErrorReportCopied } =
useTransientLabel();
const handleToggleRawMarkdown = useCallback(
() => setShowRawMarkdown((prev) => !prev),
[],
Expand All @@ -130,9 +132,8 @@ export const ChatMessage = memo(function ChatMessage({
}

await navigator.clipboard.writeText(message.error_report);
setIsErrorReportCopied(true);
window.setTimeout(() => setIsErrorReportCopied(false), 2000);
}, [message.error_report]);
triggerErrorReportCopied();
}, [message.error_report, triggerErrorReportCopied]);

// Use custom hook for image lightbox state management
const lightbox = useImageLightbox();
Expand Down
11 changes: 4 additions & 7 deletions frontend/src/components/ui/Chat/ChatShareDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { t } from "@lingui/core/macro";
import clsx from "clsx";
import { useMemo, useState } from "react";
import { useMemo } from "react";

import { InteractiveContainer } from "@/components/ui/Container/InteractiveContainer";
import { Button } from "@/components/ui/Controls/Button";
import { Alert } from "@/components/ui/Feedback/Alert";
import { Input } from "@/components/ui/Input/Input";
import { ModalBase } from "@/components/ui/Modal/ModalBase";
import { useTransientLabel } from "@/hooks/ui/useTransientLabel";
import { useChatShareLink } from "@/hooks/useChatShareLink";

import { CheckIcon, CopyIcon } from "../icons";
Expand All @@ -28,7 +29,7 @@ export function ChatShareDialog({
const { shareLink, setEnabled, isUpdating } = useChatShareLink(
isOpen ? chatId : null,
);
const [isCopied, setIsCopied] = useState(false);
const { isActive: isCopied, trigger: triggerCopied } = useTransientLabel();

const shareUrl = useMemo(() => {
if (!shareLink?.enabled) {
Expand All @@ -47,9 +48,6 @@ export function ChatShareDialog({
}

await setEnabled(enabled);
if (!enabled) {
setIsCopied(false);
}
};

const handleCopy = async () => {
Expand All @@ -58,8 +56,7 @@ export function ChatShareDialog({
}

await navigator.clipboard.writeText(shareUrl);
setIsCopied(true);
window.setTimeout(() => setIsCopied(false), 2000);
triggerCopied();
};

return (
Expand Down
99 changes: 3 additions & 96 deletions frontend/src/components/ui/Message/ActionConfirmationCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,114 +103,21 @@ describe("ActionConfirmationCard", () => {
expect(screen.getByText("Deny")).toBeDisabled();
});

it("renders a compact resolved row instead of buttons once resolved", () => {
it("moves focus to the card on mount", () => {
render(
<ActionConfirmationCard
title="t"
onAllowOnce={vi.fn()}
onDeny={vi.fn()}
status="confirmed"
resolvedLabel="Reply opened"
data-testid="card"
/>,
);
expect(screen.getByTestId("card")).toHaveTextContent("Reply opened");
expect(screen.queryByText("Allow once")).not.toBeInTheDocument();
});

it("moves focus to a pending card on mount", () => {
render(
<ActionConfirmationCard
title="t"
onAllowOnce={vi.fn()}
onDeny={vi.fn()}
data-testid="card"
/>,
);
expect(screen.getByTestId("card")).toHaveFocus();
});

it("does not grab focus when mounted already resolved", () => {
render(
<ActionConfirmationCard
title="t"
onAllowOnce={vi.fn()}
onDeny={vi.fn()}
status="dismissed"
data-testid="card"
/>,
);
expect(screen.getByTestId("card")).not.toHaveFocus();
});

it("keeps focus on the card when the pending buttons resolve away", () => {
const props = {
title: "t",
onAllowOnce: vi.fn(),
onDeny: vi.fn(),
"data-testid": "card",
};
const { rerender } = render(<ActionConfirmationCard {...props} />);
screen.getByText("Deny").focus();
rerender(<ActionConfirmationCard {...props} status="dismissed" />);
expect(screen.getByTestId("card")).toHaveFocus();
});

it("populates the polite announcement region after mount and on resolution", () => {
const props = { onAllowOnce: vi.fn(), onDeny: vi.fn() };
const { rerender } = render(<ActionConfirmationCard {...props} />);
it("populates the polite announcement region after mount", () => {
render(<ActionConfirmationCard onAllowOnce={vi.fn()} onDeny={vi.fn()} />);
expect(screen.getByRole("status")).toHaveTextContent("Allow this action?");
rerender(
<ActionConfirmationCard
{...props}
status="confirmed"
resolvedLabel="Reply opened"
/>,
);
expect(screen.getByRole("status")).toHaveTextContent("Reply opened");
});

it("announces the pending→resolved transition exactly once", async () => {
const props = { onAllowOnce: vi.fn(), onDeny: vi.fn() };
const { rerender } = render(<ActionConfirmationCard {...props} />);
const region = screen.getByRole("status");
// Live regions announce on DOM mutation, so count mutations directly.
const mutations: string[] = [];
const observer = new MutationObserver((records) => {
mutations.push(...records.map(() => region.textContent ?? ""));
});
observer.observe(region, {
childList: true,
characterData: true,
subtree: true,
});
const resolvedProps = (
<ActionConfirmationCard
{...props}
status="confirmed"
resolvedLabel="Reply opened"
/>
);
rerender(resolvedProps);
// A second render of the already-resolved card must not re-announce.
rerender(resolvedProps);
await Promise.resolve(); // flush the MutationObserver microtask
observer.disconnect();
expect(mutations).toEqual(["Reply opened"]);
});

it("does not announce a card that mounts already resolved", () => {
render(
<ActionConfirmationCard
onAllowOnce={vi.fn()}
onDeny={vi.fn()}
status="confirmed"
resolvedLabel="Reply opened"
/>,
);
// Reloaded transcripts mount resolved cards in bulk; an announcement
// here would queue one polite readout per historical card.
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});

it("scrolls into view on mount only when requested", () => {
Expand Down
Loading
Loading