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` / ``) | 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 ``** depending on scope (global vs. component-local). diff --git a/frontend/src/components/ui/Chat/ChatMessage.tsx b/frontend/src/components/ui/Chat/ChatMessage.tsx index 10ea148df..a1fd40792 100644 --- a/frontend/src/components/ui/Chat/ChatMessage.tsx +++ b/frontend/src/components/ui/Chat/ChatMessage.tsx @@ -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, @@ -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), [], @@ -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(); diff --git a/frontend/src/components/ui/Chat/ChatShareDialog.tsx b/frontend/src/components/ui/Chat/ChatShareDialog.tsx index 4267b96a5..fae791871 100644 --- a/frontend/src/components/ui/Chat/ChatShareDialog.tsx +++ b/frontend/src/components/ui/Chat/ChatShareDialog.tsx @@ -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"; @@ -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) { @@ -47,9 +48,6 @@ export function ChatShareDialog({ } await setEnabled(enabled); - if (!enabled) { - setIsCopied(false); - } }; const handleCopy = async () => { @@ -58,8 +56,7 @@ export function ChatShareDialog({ } await navigator.clipboard.writeText(shareUrl); - setIsCopied(true); - window.setTimeout(() => setIsCopied(false), 2000); + triggerCopied(); }; return ( diff --git a/frontend/src/components/ui/Message/ActionConfirmationCard.test.tsx b/frontend/src/components/ui/Message/ActionConfirmationCard.test.tsx index d46c08ef8..6581a6848 100644 --- a/frontend/src/components/ui/Message/ActionConfirmationCard.test.tsx +++ b/frontend/src/components/ui/Message/ActionConfirmationCard.test.tsx @@ -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( , ); - expect(screen.getByTestId("card")).toHaveTextContent("Reply opened"); - expect(screen.queryByText("Allow once")).not.toBeInTheDocument(); - }); - - it("moves focus to a pending card on mount", () => { - render( - , - ); - expect(screen.getByTestId("card")).toHaveFocus(); - }); - - it("does not grab focus when mounted already resolved", () => { - render( - , - ); - 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(); - screen.getByText("Deny").focus(); - rerender(); 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(); + it("populates the polite announcement region after mount", () => { + render(); expect(screen.getByRole("status")).toHaveTextContent("Allow this action?"); - rerender( - , - ); - 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(); - 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 = ( - - ); - 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( - , - ); - // 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", () => { diff --git a/frontend/src/components/ui/Message/ActionConfirmationCard.tsx b/frontend/src/components/ui/Message/ActionConfirmationCard.tsx index 90b7380c8..0de6de36a 100644 --- a/frontend/src/components/ui/Message/ActionConfirmationCard.tsx +++ b/frontend/src/components/ui/Message/ActionConfirmationCard.tsx @@ -1,13 +1,11 @@ import { t } from "@lingui/core/macro"; -import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { Button } from "../Controls/Button"; import type React from "react"; import type { ReactNode } from "react"; -export type ActionConfirmationStatus = "pending" | "confirmed" | "dismissed"; - interface ActionConfirmationCardProps { /** * Title of the permission step. Defaults to a generic "Allow this action?" @@ -39,14 +37,6 @@ interface ActionConfirmationCardProps { allowOnceLabel?: string; alwaysAllowLabel?: string; denyLabel?: string; - /** - * Lifecycle state. `pending` renders the decision buttons; - * `confirmed` / `dismissed` render a compact resolved row so the decision - * stays visible in the transcript. - */ - status?: ActionConfirmationStatus; - /** Text for the resolved row (e.g. "Reply opened"). */ - resolvedLabel?: string; /** Disables the buttons while the allowed action is executing. */ isBusy?: boolean; /** @@ -87,11 +77,14 @@ const focusIfUnclaimed = (card: HTMLElement | null) => { * case "always allow" renders greyed out with the reason. * * Unlike a modal, the card lives WITH the proposal in the conversation: it - * never steals focus, multiple proposals can be pending independently, and a - * resolved card stays visible as a record of the decision. The component is - * deliberately execution-agnostic — the callbacks may run a client-side - * action (e.g. the Outlook add-in opening a reply form) or call an approval - * endpoint for server-gated tools; the card only renders the decision step. + * never steals focus and multiple proposals can be pending independently. The + * component is deliberately execution-agnostic — the callbacks may run a + * client-side action or call an approval endpoint for server-gated tools; the + * card only renders the decision step. + * + * The resolved-row persistent record (status / resolvedLabel props) was + * removed in 2026-07: callers now close the card on resolution and show + * transient button feedback (e.g. "Opened!") using `useTransientLabel`. */ export const ActionConfirmationCard: React.FC = ({ title, @@ -103,8 +96,6 @@ export const ActionConfirmationCard: React.FC = ({ allowOnceLabel, alwaysAllowLabel, denyLabel, - status = "pending", - resolvedLabel, isBusy = false, scrollIntoViewOnMount = false, className = "", @@ -112,149 +103,102 @@ export const ActionConfirmationCard: React.FC = ({ }) => { const cardRef = useRef(null); const alwaysAllowReasonId = useId(); - const isPending = status === "pending"; useEffect(() => { if (scrollIntoViewOnMount) { cardRef.current?.scrollIntoView({ block: "nearest" }); } - if (isPending) { - // A card mounted already-resolved (e.g. a reloaded transcript) must - // not grab focus. - focusIfUnclaimed(cardRef.current); - } + focusIfUnclaimed(cardRef.current); // Mount-only by design: re-scrolling on later prop changes would yank // the viewport while the user reads or scrolls elsewhere. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const previousStatusRef = useRef(status); - // Layout effect so focus moves before paint — with a passive effect, focus - // would sit on for a frame after the button row unmounts before - // focusIfUnclaimed claims it. - useLayoutEffect(() => { - const previousStatus = previousStatusRef.current; - previousStatusRef.current = status; - if (previousStatus === "pending" && status !== "pending") { - focusIfUnclaimed(cardRef.current); - } - }, [status]); - - const resolvedTitle = + const cardTitle = title ?? t({ id: "actionConfirmation.title", message: "Allow this action?", }); - const resolvedRowLabel = - resolvedLabel ?? - (status === "confirmed" - ? t({ - id: "actionConfirmation.confirmed", - message: "Confirmed", - }) - : t({ - id: "actionConfirmation.dismissed", - message: "Dismissed", - })); - // Live regions only announce mutations, so the region starts empty and is - // populated after paint — both the card's appearance and the - // pending→resolved transition then announce politely (non-modal by - // design: announce instead of trapping focus). A card mounted - // already-resolved (e.g. a reloaded transcript) stays silent, otherwise - // every historical card would queue an announcement. + // populated after paint — the card's appearance then announces politely + // (non-modal by design: announce instead of trapping focus). const [announcement, setAnnouncement] = useState(""); - const wasPendingAtMountRef = useRef(isPending); - const announcementText = isPending ? resolvedTitle : resolvedRowLabel; useEffect(() => { - if (wasPendingAtMountRef.current) { - setAnnouncement(announcementText); - } - }, [announcementText]); + setAnnouncement(cardTitle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
- // when the trigger disables on open or the button row unmounts. + // when the trigger disables on open. tabIndex={-1} - role={isPending ? "group" : undefined} - aria-label={isPending ? resolvedTitle : undefined} - className={ - isPending - ? `mt-2 space-y-2 rounded-md border border-theme-border bg-theme-bg-primary p-3 ${className}` - : `mt-2 rounded-md border border-theme-border bg-theme-bg-primary px-3 py-2 text-xs text-theme-fg-muted ${className}` - } + role="group" + aria-label={cardTitle} + className={`mt-2 space-y-2 rounded-md border border-theme-border bg-theme-bg-primary p-3 ${className}`} data-testid={dataTestId} > - {isPending ? ( - <> -

- {resolvedTitle} -

- {typeof description === "string" ? ( -

{description}

- ) : ( - description - )} -
- - {onAlwaysAllow && ( - - )} - -
- {alwaysAllowDisabledReason && ( -

- {alwaysAllowDisabledReason} -

- )} - +

{cardTitle}

+ {typeof description === "string" ? ( +

{description}

) : ( - resolvedRowLabel + description + )} +
+ + {onAlwaysAllow && ( + + )} + +
+ {alwaysAllowDisabledReason && ( +

+ {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 (

diff --git a/frontend/src/components/ui/Message/MessageContent.tsx b/frontend/src/components/ui/Message/MessageContent.tsx index 68917173d..1e26e33d8 100644 --- a/frontend/src/components/ui/Message/MessageContent.tsx +++ b/frontend/src/components/ui/Message/MessageContent.tsx @@ -19,6 +19,7 @@ import { } from "@/config/codeHighlightThemes"; import { componentRegistry } from "@/config/componentRegistry"; import { useOptionalTranslation } from "@/hooks/i18n"; +import { useTransientLabel } from "@/hooks/ui/useTransientLabel"; import { useTraceFeature } from "@/providers/FeatureConfigProvider"; import { FileTypeUtil } from "@/utils/fileTypes"; @@ -197,7 +198,11 @@ function MarkdownPre({ ...props }: MarkdownPreProps) { const artifact = React.useContext(OutlookArtifactContext); - const [copied, setCopied] = React.useState(false); + const { + isActive: copied, + trigger: triggerCopied, + srAnnouncement, + } = useTransientLabel({ announcement: t`Copied to clipboard` }); // Extract the raw code text from the child 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 ""