diff --git a/frontend/src/components/README.md b/frontend/src/components/README.md new file mode 100644 index 000000000..bf986a28a --- /dev/null +++ b/frontend/src/components/README.md @@ -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 ( + <> + + {/* sr-only live region — include whenever the visible change is icon-only */} +
+ {srAnnouncement} +
+ > + ); +} +``` + +**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` / `- {resolvedTitle} -
- {typeof description === "string" ? ( -{description}
- ) : ( - description - )} -- {alwaysAllowDisabledReason} -
- )} - > +{cardTitle}
+ {typeof description === "string" ? ( +{description}
) : ( - resolvedRowLabel + description + )} ++ {alwaysAllowDisabledReason} +
)}{announcement} diff --git a/frontend/src/components/ui/Message/DefaultMessageControls.tsx b/frontend/src/components/ui/Message/DefaultMessageControls.tsx index b244e306a..511603f3f 100644 --- a/frontend/src/components/ui/Message/DefaultMessageControls.tsx +++ b/frontend/src/components/ui/Message/DefaultMessageControls.tsx @@ -6,6 +6,7 @@ import { memo, useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/Controls/Button"; import { MessageTimestamp } from "@/components/ui/Message/MessageTimestamp"; +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { createLogger } from "@/utils/debugLogger"; import { @@ -40,7 +41,7 @@ export const DefaultMessageControls = memo(function DefaultMessageControls({ hasToolCalls = false, onViewFeedback, }: MessageControlsProps) { - const [isCopied, setIsCopied] = useState(false); + const { isActive: isCopied, trigger: triggerCopied } = useTransientLabel(); const controlsRowStyle = { gap: "var(--theme-spacing-control-gap)", } as const; @@ -73,15 +74,6 @@ export const DefaultMessageControls = memo(function DefaultMessageControls({ } }, [initialFeedback]); - useEffect(() => { - if (isCopied) { - const timer = setTimeout(() => { - setIsCopied(false); - }, 2000); - return () => clearTimeout(timer); - } - }, [isCopied]); - const handleAction = useCallback( async (actionType: "copy" | "edit" | "regenerate" | "like" | "dislike") => { // Determine if user is clicking the same sentiment they already submitted @@ -122,7 +114,7 @@ export const DefaultMessageControls = memo(function DefaultMessageControls({ if (success) { if (actionType === "copy") { - setIsCopied(true); + triggerCopied(); } else if (actionType === "like") { setFeedbackState("liked"); } else if (actionType === "dislike") { @@ -133,7 +125,14 @@ export const DefaultMessageControls = memo(function DefaultMessageControls({ logger.log(`Action '${actionType}' failed for message ${messageId}`); } }, - [onAction, messageId, feedbackState, initialFeedback, onViewFeedback], + [ + onAction, + messageId, + feedbackState, + initialFeedback, + onViewFeedback, + triggerCopied, + ], ); // Ensure safeCreatedAt is always a Date object diff --git a/frontend/src/components/ui/Message/EratoEmailSuggestion.tsx b/frontend/src/components/ui/Message/EratoEmailSuggestion.tsx index fd0f24754..5f30ab136 100644 --- a/frontend/src/components/ui/Message/EratoEmailSuggestion.tsx +++ b/frontend/src/components/ui/Message/EratoEmailSuggestion.tsx @@ -1,7 +1,8 @@ import { t } from "@lingui/core/macro"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { componentRegistry } from "@/config/componentRegistry"; +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { copyEmailToClipboard } from "@/utils/emailClipboard"; import { sanitizeHtmlPreview } from "@/utils/sanitizeHtmlPreview"; @@ -18,19 +19,18 @@ export function DefaultEratoEmailCodeBlock({ content, isHtml, }: EratoEmailCodeBlockProps) { - const [copied, setCopied] = useState(false); + const { isActive: copied, trigger: triggerCopied } = useTransientLabel(); const previewHtml = useMemo(() => sanitizeHtmlPreview(content), [content]); const handleCopy = useCallback(() => { void copyEmailToClipboard(content, isHtml ?? false) .then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); + triggerCopied(); }) .catch(() => { // Fallback: ignore clipboard errors }); - }, [content, isHtml]); + }, [content, isHtml, triggerCopied]); return (
element so the copy button
// can access it without needing a separate context or ref strategy.
@@ -215,11 +220,10 @@ function MarkdownPre({
void navigator.clipboard
.writeText(codeContent)
.then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
+ triggerCopied();
})
.catch(() => {});
- }, [codeContent]);
+ }, [codeContent, triggerCopied]);
// erato-email / erato-appointment blocks render a custom component, not a
// code block — use a plain to avoid inheriting monospace font
@@ -263,6 +267,9 @@ function MarkdownPre({
)}
+
+ {srAnnouncement}
+
);
}
diff --git a/frontend/src/customer/examples/MessageControls.example.tsx b/frontend/src/customer/examples/MessageControls.example.tsx
index 0dd48428c..ada8c8fd9 100644
--- a/frontend/src/customer/examples/MessageControls.example.tsx
+++ b/frontend/src/customer/examples/MessageControls.example.tsx
@@ -25,7 +25,7 @@
*/
import clsx from "clsx";
-import { useState, useEffect, useCallback } from "react";
+import { useCallback, useState } from "react";
import { Button } from "@/components/ui/Controls/Button";
import { DropdownMenu } from "@/components/ui/Controls/DropdownMenu";
@@ -38,6 +38,7 @@ import {
ShareIcon,
MoreVertical,
} from "@/components/ui/icons";
+import { useTransientLabel } from "@/hooks/ui/useTransientLabel";
import { createLogger } from "@/utils/debugLogger";
import type { DropdownMenuItem } from "@/components/ui/Controls/DropdownMenu";
@@ -108,7 +109,7 @@ export const MessageControls = ({
showRawMarkdown = false,
onToggleRawMarkdown,
}: MessageControlsProps) => {
- const [isCopied, setIsCopied] = useState(false);
+ const { isActive: isCopied, trigger: triggerCopied } = useTransientLabel();
// Emoji reactions state (demo with mock data)
const [reactions, setReactions] = useState>({
@@ -126,14 +127,6 @@ export const MessageControls = ({
// Chat-level edit permission
const canEditChat = context.canEdit !== false;
- // Reset copy state after 2 seconds
- useEffect(() => {
- if (isCopied) {
- const timer = setTimeout(() => setIsCopied(false), 2000);
- return () => clearTimeout(timer);
- }
- }, [isCopied]);
-
// Mock metadata (extend MessageControlsProps to pass real data)
const metadata: MessageMetadata = {
model: isUserMessage ? undefined : "GPT-4",
@@ -146,10 +139,10 @@ export const MessageControls = ({
const handleCopy = useCallback(async () => {
const success = await onAction({ type: "copy", messageId });
if (success) {
- setIsCopied(true);
+ triggerCopied();
logger.log(`Copy succeeded for message ${messageId}`);
}
- }, [onAction, messageId]);
+ }, [onAction, messageId, triggerCopied]);
const handleEdit = useCallback(async () => {
const success = await onAction({ type: "edit", messageId });
diff --git a/frontend/src/hooks/ui/index.ts b/frontend/src/hooks/ui/index.ts
index 898f79b95..6b13cbd6a 100644
--- a/frontend/src/hooks/ui/index.ts
+++ b/frontend/src/hooks/ui/index.ts
@@ -12,3 +12,4 @@ export * from "./useScrollEvents";
export * from "./useFilePreviewModal";
export * from "./useThemedIcon";
export * from "./usePageAlignment";
+export * from "./useTransientLabel";
diff --git a/frontend/src/hooks/ui/useTransientLabel.ts b/frontend/src/hooks/ui/useTransientLabel.ts
new file mode 100644
index 000000000..185586f0c
--- /dev/null
+++ b/frontend/src/hooks/ui/useTransientLabel.ts
@@ -0,0 +1,85 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+interface UseTransientLabelOptions {
+ /** Duration the active state stays on before auto-resetting. Default: 2000 ms. */
+ delay?: number;
+ /**
+ * Text to surface in the sr-only live region while active, for screen
+ * readers that would otherwise only observe a silent icon/label swap.
+ * When omitted no `srAnnouncement` text is produced.
+ */
+ announcement?: string;
+}
+
+interface UseTransientLabelResult {
+ /** True for `delay` ms after `trigger` is called, then false. */
+ isActive: boolean;
+ /**
+ * Call this to start the transient active window. Safe to call while
+ * already active — restarts the timer from zero.
+ */
+ trigger: () => void;
+ /**
+ * Non-empty while `isActive` and an `announcement` was supplied; empty
+ * string otherwise. Render inside a `role="status"` / `aria-live="polite"`
+ * sr-only element to announce success to screen readers that would not
+ * detect a silent icon swap.
+ *
+ * @example
+ * {srAnnouncement}
+ */
+ srAnnouncement: string;
+}
+
+/**
+ * Drives the transient ~2 s label-swap success feedback pattern.
+ *
+ * `isActive` becomes `true` when `trigger` is called and automatically resets
+ * to `false` after `delay` ms (default 2000). The timer is cleared if the
+ * component unmounts before it fires — no timer leaks. Calling `trigger`
+ * while already active restarts the timer.
+ *
+ * @example
+ * const { isActive: isCopied, trigger: triggerCopied, srAnnouncement } =
+ * useTransientLabel({ announcement: t`Copied to clipboard` });
+ *
+ * // in handler:
+ * await navigator.clipboard.writeText(text);
+ * triggerCopied();
+ *
+ * // in render:
+ * {isCopied ? : }
+ * {srAnnouncement}
+ */
+export function useTransientLabel({
+ delay = 2000,
+ announcement,
+}: UseTransientLabelOptions = {}): UseTransientLabelResult {
+ const [isActive, setIsActive] = useState(false);
+ const timerRef = useRef | null>(null);
+
+ const trigger = useCallback(() => {
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current);
+ }
+ setIsActive(true);
+ timerRef.current = setTimeout(() => {
+ setIsActive(false);
+ timerRef.current = null;
+ }, delay);
+ }, [delay]);
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current);
+ }
+ };
+ }, []);
+
+ return {
+ isActive,
+ trigger,
+ srAnnouncement: isActive && announcement ? announcement : "",
+ };
+}
diff --git a/frontend/src/library/index.ts b/frontend/src/library/index.ts
index d24a8a9c6..24720dcac 100644
--- a/frontend/src/library/index.ts
+++ b/frontend/src/library/index.ts
@@ -27,10 +27,7 @@ export { DefaultMessageControls } from "@/components/ui/Message/DefaultMessageCo
export { DefaultEratoEmailCodeBlock } from "@/components/ui/Message/EratoEmailSuggestion";
export { FilePreviewModal } from "@/components/ui/Modal/FilePreviewModal";
export { ModalBase } from "@/components/ui/Modal/ModalBase";
-export {
- ActionConfirmationCard,
- type ActionConfirmationStatus,
-} from "@/components/ui/Message/ActionConfirmationCard";
+export { ActionConfirmationCard } from "@/components/ui/Message/ActionConfirmationCard";
export { AppearanceTabContent } from "@/components/ui/Settings/AppearanceTabContent";
export { AudioInputTabContent } from "@/components/ui/Settings/AudioInputTabContent";
export {
diff --git a/frontend/src/locales/de/messages.po b/frontend/src/locales/de/messages.po
index a0449b0b0..5daafec61 100644
--- a/frontend/src/locales/de/messages.po
+++ b/frontend/src/locales/de/messages.po
@@ -33,8 +33,8 @@ msgstr "Immer erlauben"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.confirmed"
-msgstr "Bestätigt"
+#~ msgid "actionConfirmation.confirmed"
+#~ msgstr "Bestätigt"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -43,8 +43,8 @@ msgstr "Ablehnen"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.dismissed"
-msgstr "Verworfen"
+#~ msgid "actionConfirmation.dismissed"
+#~ msgstr "Verworfen"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -3053,6 +3053,10 @@ msgstr ""
msgid "Copied"
msgstr ""
+#: src/components/ui/Message/MessageContent.tsx
+msgid "Copied to clipboard"
+msgstr ""
+
#: src/components/ui/Message/EratoEmailSuggestion.tsx
msgid "Copied!"
msgstr ""
diff --git a/frontend/src/locales/en/messages.po b/frontend/src/locales/en/messages.po
index 7538b1a4c..022922b73 100644
--- a/frontend/src/locales/en/messages.po
+++ b/frontend/src/locales/en/messages.po
@@ -33,8 +33,8 @@ msgstr "Always allow"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.confirmed"
-msgstr "Confirmed"
+#~ msgid "actionConfirmation.confirmed"
+#~ msgstr "Confirmed"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -43,8 +43,8 @@ msgstr "Deny"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.dismissed"
-msgstr "Dismissed"
+#~ msgid "actionConfirmation.dismissed"
+#~ msgstr "Dismissed"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -3053,6 +3053,10 @@ msgstr "Conversational"
msgid "Copied"
msgstr "Copied"
+#: src/components/ui/Message/MessageContent.tsx
+msgid "Copied to clipboard"
+msgstr "Copied to clipboard"
+
#: src/components/ui/Message/EratoEmailSuggestion.tsx
msgid "Copied!"
msgstr "Copied!"
diff --git a/frontend/src/locales/es/messages.po b/frontend/src/locales/es/messages.po
index 69e629743..dcefac884 100644
--- a/frontend/src/locales/es/messages.po
+++ b/frontend/src/locales/es/messages.po
@@ -33,8 +33,8 @@ msgstr "Permitir siempre"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.confirmed"
-msgstr "Confirmado"
+#~ msgid "actionConfirmation.confirmed"
+#~ msgstr "Confirmado"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -43,8 +43,8 @@ msgstr "Denegar"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.dismissed"
-msgstr "Descartado"
+#~ msgid "actionConfirmation.dismissed"
+#~ msgstr "Descartado"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -3053,6 +3053,10 @@ msgstr ""
msgid "Copied"
msgstr ""
+#: src/components/ui/Message/MessageContent.tsx
+msgid "Copied to clipboard"
+msgstr ""
+
#: src/components/ui/Message/EratoEmailSuggestion.tsx
msgid "Copied!"
msgstr ""
diff --git a/frontend/src/locales/fr/messages.po b/frontend/src/locales/fr/messages.po
index 8a2b7f51c..665cbf5a9 100644
--- a/frontend/src/locales/fr/messages.po
+++ b/frontend/src/locales/fr/messages.po
@@ -33,8 +33,8 @@ msgstr "Toujours autoriser"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.confirmed"
-msgstr "Confirmé"
+#~ msgid "actionConfirmation.confirmed"
+#~ msgstr "Confirmé"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -43,8 +43,8 @@ msgstr "Refuser"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.dismissed"
-msgstr "Ignoré"
+#~ msgid "actionConfirmation.dismissed"
+#~ msgstr "Ignoré"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -3053,6 +3053,10 @@ msgstr ""
msgid "Copied"
msgstr ""
+#: src/components/ui/Message/MessageContent.tsx
+msgid "Copied to clipboard"
+msgstr ""
+
#: src/components/ui/Message/EratoEmailSuggestion.tsx
msgid "Copied!"
msgstr ""
diff --git a/frontend/src/locales/pl/messages.po b/frontend/src/locales/pl/messages.po
index 07656a576..2c4ffaa21 100644
--- a/frontend/src/locales/pl/messages.po
+++ b/frontend/src/locales/pl/messages.po
@@ -33,8 +33,8 @@ msgstr "Zawsze zezwalaj"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.confirmed"
-msgstr "Potwierdzono"
+#~ msgid "actionConfirmation.confirmed"
+#~ msgstr "Potwierdzono"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -43,8 +43,8 @@ msgstr "Odmów"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
-msgid "actionConfirmation.dismissed"
-msgstr "Odrzucono"
+#~ msgid "actionConfirmation.dismissed"
+#~ msgstr "Odrzucono"
#. js-lingui-explicit-id
#: src/components/ui/Message/ActionConfirmationCard.tsx
@@ -3053,6 +3053,10 @@ msgstr ""
msgid "Copied"
msgstr ""
+#: src/components/ui/Message/MessageContent.tsx
+msgid "Copied to clipboard"
+msgstr ""
+
#: src/components/ui/Message/EratoEmailSuggestion.tsx
msgid "Copied!"
msgstr ""