From 5a2d23849f7af36e3c71779af2ee023095e9071e Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Sat, 17 Oct 2026 04:05:28 -0700 Subject: [PATCH 1/4] Extract the copied state of the Copy button into useCopiedState() Moves the 5 second timer of the "Copied" confirmation (#5872) out of ActivityCopyButton, so the copy button of code blocks can share it. The ponyfill is passed in, because that button renders outside of Web Chat's React tree. --- .../Text/private/ActivityCopyButton.tsx | 21 ++++-------- .../src/hooks/internal/useCopiedState.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 packages/component/src/hooks/internal/useCopiedState.ts diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx index 4a3c11bd4c..3d9d461baa 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx @@ -2,9 +2,10 @@ import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { hooks } from 'botframework-webchat-api'; import cx from 'classnames'; -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useState } from 'react'; import { instance, nullable, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; +import useCopiedState from '../../../hooks/internal/useCopiedState'; import { useQueueStaticElement } from '../../../providers/LiveRegionTwin'; import refObject from '../../../types/internal/refObject'; import ActivityButton from './ActivityButton'; @@ -13,8 +14,6 @@ import styles from './ActivityCopyButton.module.css'; const { useLocalizer, usePonyfill, useUIState } = hooks; -const COPIED_DURATION = 5_000; - const activityCopyButtonPropsSchema = pipe( object({ className: optional(string()), @@ -28,15 +27,14 @@ type ActivityCopyButtonProps = InferInput; function ActivityCopyButton(props: ActivityCopyButtonProps) { const { className, targetRef } = validateProps(activityCopyButtonPropsSchema, props); - const [{ clearTimeout, setTimeout }] = usePonyfill(); - const [copied, setCopied] = useState(false); const [permissionGranted, setPermissionGranted] = useState(false); + const [ponyfill] = usePonyfill(); const [uiState] = useUIState(); const classNames = useStyles(styles); - const copiedTimeoutRef = useRef | undefined>(); const localize = useLocalizer(); const queueStaticElement = useQueueStaticElement(); + const [copied, markAsCopied] = useCopiedState(ponyfill); const copiedText = localize('COPY_BUTTON_COPIED_TEXT'); const copyText = localize('COPY_BUTTON_TEXT'); const disabled = !permissionGranted || uiState === 'disabled'; @@ -55,17 +53,10 @@ function ActivityCopyButton(props: ActivityCopyButtonProps) { .catch(error => console.error(`botframework-webchat-fluent-theme: Failed to copy to clipboard.`, error)); // Activating again restarts the confirmation. - copiedTimeoutRef.current && clearTimeout(copiedTimeoutRef.current); - copiedTimeoutRef.current = setTimeout(() => setCopied(false), COPIED_DURATION); - setCopied(true); + markAsCopied(); queueStaticElement(
{copiedText}
); - }, [classNames, clearTimeout, copiedText, copiedTimeoutRef, queueStaticElement, setCopied, setTimeout, targetRef]); - - useEffect( - () => () => copiedTimeoutRef.current && clearTimeout(copiedTimeoutRef.current), - [clearTimeout, copiedTimeoutRef] - ); + }, [classNames, copiedText, markAsCopied, queueStaticElement, targetRef]); useEffect(() => { let unmounted = false; diff --git a/packages/component/src/hooks/internal/useCopiedState.ts b/packages/component/src/hooks/internal/useCopiedState.ts new file mode 100644 index 0000000000..c4feb003c4 --- /dev/null +++ b/packages/component/src/hooks/internal/useCopiedState.ts @@ -0,0 +1,32 @@ +import { type GlobalScopePonyfill } from 'botframework-webchat-core'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +const COPIED_DURATION = 5_000; + +/** + * State of the "Copied" confirmation of a copy button: it is shown for 5 seconds after `markAsCopied()`, calling it again restarts the 5 seconds. + * + * The ponyfill is passed in, so copy buttons rendered outside of the React tree of Web Chat (custom elements) can use it. + * + * @returns A tuple of whether the confirmation should be shown, and the function to call after copying. + */ +export default function useCopiedState({ + clearTimeout, + setTimeout +}: Pick): readonly [boolean, () => void] { + const [copied, setCopied] = useState(false); + const copiedTimeoutRef = useRef | undefined>(); + + const markAsCopied = useCallback(() => { + copiedTimeoutRef.current && clearTimeout(copiedTimeoutRef.current); + copiedTimeoutRef.current = setTimeout(() => setCopied(false), COPIED_DURATION); + setCopied(true); + }, [clearTimeout, copiedTimeoutRef, setCopied, setTimeout]); + + useEffect( + () => () => copiedTimeoutRef.current && clearTimeout(copiedTimeoutRef.current), + [clearTimeout, copiedTimeoutRef] + ); + + return useMemo(() => Object.freeze([copied, markAsCopied] as const), [copied, markAsCopied]); +} From fddc124aacc12054e0a8308f868a457a8e67f967 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Sat, 17 Oct 2026 04:05:28 -0700 Subject: [PATCH 2/4] Show the code block copy confirmation for 5 seconds, without a live region The copy button of code blocks showed its checkmark and "Copied" name for 0.5 seconds, driven by a CSS animation, and was an assertive live region that announced "Copied" and then "Copy" on top of its name changes. - Use useCopiedState() (5 seconds, restarted on copy), with the ponyfill passed to the custom element by useReactCodeBlockCopyButtonClass() - Remove aria-live and aria-pressed: the name of the button conveys "Copied", like the Copy button of messages - Keep the global class code-block-copy-button--copied for host styling - Remove the 0.5 second animation and the CSS that only mattered for it - Tests: fake clock and waitFor() instead of sleeps and unstable snapshots --- CHANGELOG.md | 1 + .../adaptiveCards/behavior.html | 33 ++- .../behavior.highlighted.html | 26 ++- .../codeBlockCopyButton/behavior.html | 95 ++++++-- .../CustomElements/CustomElementsComposer.tsx | 8 +- .../CodeBlockCopyButton.module.css | 17 -- .../customElements/CodeBlockCopyButton.tsx | 202 ++++++++++-------- 7 files changed, 242 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b20085eee1..bfb3b318f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Legends: - Fixed the keyboard focus indicator of card action buttons, such as hero card buttons, having less than 3:1 contrast against a pressed button: it is now a two-tone ring that is visible against both the button and the card, fixes [#5742](https://github.com/microsoft/BotFramework-WebChat/issues/5742), in PR [#5879](https://github.com/microsoft/BotFramework-WebChat/pull/5879), by [@OEvgeny](https://github.com/OEvgeny) - Fixed the keyboard focus indicator around the transcript ignoring the `transcriptVisualKeyboardIndicatorColor` style option and using the text color instead; in the Fluent theme, it now uses the Fluent focus stroke color, in PR [#5888](https://github.com/microsoft/BotFramework-WebChat/pull/5888), by [@OEvgeny](https://github.com/OEvgeny) - Fixed the keyboard focus indicator of audio and video attachments, and of videos in animation cards: the bubble clipped the browser focus ring, and the rest was covered by the audio player or not visible over a dark video; it is now shown on all four sides of the player, fixes [#5740](https://github.com/microsoft/BotFramework-WebChat/issues/5740), in PR [#5887](https://github.com/microsoft/BotFramework-WebChat/pull/5887), by [@OEvgeny](https://github.com/OEvgeny) +- Fixed the "Copied" confirmation of the copy button of code blocks disappearing too quickly: it is now shown for 5 seconds in all themes, like the Copy button of messages; the button is no longer an assertive live region, which announced "Copied" on top of its new name and "Copy" again when the confirmation ended, related to [#5857](https://github.com/microsoft/BotFramework-WebChat/issues/5857), in PR [#XXXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXXX), by [@OEvgeny](https://github.com/OEvgeny) ## [4.19.2] - 2026-10-08 diff --git a/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html b/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html index 4e1e722f57..539edd03fa 100644 --- a/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html +++ b/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html @@ -2,7 +2,7 @@ - + @@ -11,7 +11,16 @@
- + + @@ -10,8 +10,17 @@ -
- + + @@ -10,8 +10,17 @@ -
- + diff --git a/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx b/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx index 1c53e71821..f4c52efab2 100644 --- a/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx +++ b/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx @@ -2,7 +2,7 @@ import mathRandom from 'math-random'; import React, { memo, useCallback, useMemo, type ReactNode } from 'react'; import useReactCodeBlockClass from './customElements/CodeBlock'; -import { CodeBlockCopyButtonElement } from './customElements/CodeBlockCopyButton'; +import useReactCodeBlockCopyButtonClass from './customElements/CodeBlockCopyButton'; import CustomElementsContext from './private/CustomElementsContext'; type CustomElementsComposerProps = Readonly<{ @@ -35,9 +35,11 @@ const CustomElementsComposer = ({ children }: CustomElementsComposerProps) => { [hash] ); + const CodeBlockCopyButtonClass = useReactCodeBlockCopyButtonClass(); + const codeBlockCopyButtonTagName = useMemo( - () => registerCustomElement('code-block-copy-button', CodeBlockCopyButtonElement), - [registerCustomElement] + () => registerCustomElement('code-block-copy-button', CodeBlockCopyButtonClass), + [CodeBlockCopyButtonClass, registerCustomElement] ); const CodeBlockClass = useReactCodeBlockClass(codeBlockCopyButtonTagName); diff --git a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.module.css b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.module.css index 13efbd6b7a..7830569f2c 100644 --- a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.module.css +++ b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.module.css @@ -44,20 +44,3 @@ --webchat__component-icon--color: #707070; --webchat__component-icon--size: 16px; } - -:global(.webchat) .code-block-copy-button__icon--copied { - opacity: 0; -} - -:global(.webchat) .code-block-copy-button--copied .code-block-copy-button__icon { - animation: code-block-copy-button__copied-animation 0.5s linear; -} - -@keyframes code-block-copy-button__copied-animation { - 0% { - opacity: 100%; - } - 100% { - opacity: unset; - } -} diff --git a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx index ae69548471..c83e48be49 100644 --- a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx +++ b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx @@ -1,108 +1,140 @@ -import cx from 'classnames'; +import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; import { useStyles } from '@msinternal/botframework-webchat-styles/react'; -import React, { memo, useCallback, useState } from 'react'; +import { hooks } from 'botframework-webchat-api'; +import { type GlobalScopePonyfill } from 'botframework-webchat-core'; +import cx from 'classnames'; +import React, { memo, useCallback, useMemo } from 'react'; import { useRefFrom } from 'use-ref-from'; import { useStateWithRef } from 'use-state-with-ref'; +import { custom, function_, is, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; +import useCopiedState from '../../../hooks/internal/useCopiedState'; import { ComponentIcon } from '../../../Icon'; import testIds from '../../../testIds'; import wrapAsCustomElement from './wrapAsCustomElement'; import styles from './CodeBlockCopyButton.module.css'; -export type CodeBlockCopyButtonProps = Readonly<{ - className: string; - 'data-alt-copied': string; - 'data-alt-copy': string; - 'data-value': string; -}>; +const { usePonyfill } = hooks; + +const codeBlockCopyButtonPropsSchema = pipe( + object({ + className: optional(string()), + 'data-alt-copied': optional(string()), + 'data-alt-copy': optional(string()), + 'data-value': optional(string()), + ponyfill: custom>(value => + is(object({ clearTimeout: function_(), setTimeout: function_() }), value) + ) + }), + readonly() +); + +type CodeBlockCopyButtonProps = InferInput; -const CodeBlockCopyButton = memo( - ({ +function CodeBlockCopyButton(props: CodeBlockCopyButtonProps) { + const { className, 'data-alt-copied': copiedAlt, 'data-alt-copy': copyAlt, - 'data-value': value - }: CodeBlockCopyButtonProps) => { - const classNames = useStyles(styles); - const [disabled, setDisabled, disabledRef] = useStateWithRef(false); - const [pressed, setPressed] = useState(false); - const valueRef = useRefFrom(value); - - const handleClick = useCallback(() => { - if (disabledRef.current) { - return; + 'data-value': value, + ponyfill + } = validateProps(codeBlockCopyButtonPropsSchema, props); + + const [copied, markAsCopied] = useCopiedState(ponyfill); + const [disabled, setDisabled, disabledRef] = useStateWithRef(false); + const classNames = useStyles(styles); + const valueRef = useRefFrom(value); + + const handleClick = useCallback(() => { + if (disabledRef.current) { + return; + } + + let obsoleted = false; + + (async () => { + try { + const { state } = await navigator.permissions.query({ name: 'clipboard-write' as any }); + + if (!obsoleted) { + if (state === 'granted') { + await navigator.clipboard?.write([ + new ClipboardItem({ 'text/plain': new Blob([valueRef.current], { type: 'text/plain' }) }) + ]); + + // Copying again restarts the confirmation. + obsoleted || markAsCopied(); + } else if (state === 'denied') { + setDisabled(true); + } + } + } catch (error) { + console.warn('botframework-webchat: Failed to copy code block to clipboard.', error); } + })(); + + return () => { + obsoleted = true; + }; + }, [disabledRef, markAsCopied, setDisabled, valueRef]); + + // A plain button, like the copy button of activities: its name is "Copied" while the confirmation is shown. + // A live region on the button would raise another event for "Copied", then an assertive "Copy" when the confirmation ends. + // The "--copied" classes have no style of their own, they are kept for hosts to style. + return ( + + ); +} - let obsoleted = false; - - (async () => { - try { - const { state } = await navigator.permissions.query({ name: 'clipboard-write' as any }); +CodeBlockCopyButton.displayName = 'CodeBlockCopyButton'; - if (!obsoleted) { - if (state === 'granted') { - await navigator.clipboard?.write([ - new ClipboardItem({ 'text/plain': new Blob([valueRef.current], { type: 'text/plain' }) }) - ]); +const MemoizedCodeBlockCopyButton = memo(CodeBlockCopyButton); - obsoleted || setPressed(true); - } else if (state === 'denied') { - setDisabled(true); - } - } - } catch (error) { - console.warn('botframework-webchat: Failed to copy code block to clipboard.', error); - } - })(); - - return () => { - obsoleted = true; - }; - }, [disabledRef, setDisabled, setPressed, valueRef]); - - const handleAnimationEnd = useCallback(() => setPressed(false), [setPressed]); - - return ( - - ); +function createCodeBlockCopyButtonClass(ponyfill: CodeBlockCopyButtonProps['ponyfill']): typeof HTMLElement { + function CodeBlockCopyButtonWithPonyfill(props: Omit) { + return ; } -); -CodeBlockCopyButton.displayName = 'CodeBlockCopyButton'; + CodeBlockCopyButtonWithPonyfill.displayName = 'CodeBlockCopyButtonWithPonyfill'; + + return wrapAsCustomElement(CodeBlockCopyButtonWithPonyfill, [ + 'className', + 'data-alt-copied', + 'data-alt-copy', + 'data-value' + ]); +} -const CodeBlockCopyButtonElement = wrapAsCustomElement(CodeBlockCopyButton, [ - 'className', - 'data-alt-copied', - 'data-alt-copy', - 'data-value' -]); +/** + * Creates the custom element class of the copy button of code blocks. + * + * The custom element renders in its own React root, outside of the React tree of Web Chat, so the ponyfill is passed down. + */ +export default function useReactCodeBlockCopyButtonClass(): typeof HTMLElement { + const [ponyfill] = usePonyfill(); -export default CodeBlockCopyButton; + return useMemo(() => createCodeBlockCopyButtonClass(ponyfill), [ponyfill]); +} -export { CodeBlockCopyButtonElement }; +export { codeBlockCopyButtonPropsSchema, type CodeBlockCopyButtonProps }; From fc9fa5440e7328647e1a291a246f0e901bd4add5 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Sat, 17 Oct 2026 04:36:16 -0700 Subject: [PATCH 3/4] Add PR number to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb3b318f0..ca02ebdffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ Legends: - Fixed the keyboard focus indicator of card action buttons, such as hero card buttons, having less than 3:1 contrast against a pressed button: it is now a two-tone ring that is visible against both the button and the card, fixes [#5742](https://github.com/microsoft/BotFramework-WebChat/issues/5742), in PR [#5879](https://github.com/microsoft/BotFramework-WebChat/pull/5879), by [@OEvgeny](https://github.com/OEvgeny) - Fixed the keyboard focus indicator around the transcript ignoring the `transcriptVisualKeyboardIndicatorColor` style option and using the text color instead; in the Fluent theme, it now uses the Fluent focus stroke color, in PR [#5888](https://github.com/microsoft/BotFramework-WebChat/pull/5888), by [@OEvgeny](https://github.com/OEvgeny) - Fixed the keyboard focus indicator of audio and video attachments, and of videos in animation cards: the bubble clipped the browser focus ring, and the rest was covered by the audio player or not visible over a dark video; it is now shown on all four sides of the player, fixes [#5740](https://github.com/microsoft/BotFramework-WebChat/issues/5740), in PR [#5887](https://github.com/microsoft/BotFramework-WebChat/pull/5887), by [@OEvgeny](https://github.com/OEvgeny) -- Fixed the "Copied" confirmation of the copy button of code blocks disappearing too quickly: it is now shown for 5 seconds in all themes, like the Copy button of messages; the button is no longer an assertive live region, which announced "Copied" on top of its new name and "Copy" again when the confirmation ended, related to [#5857](https://github.com/microsoft/BotFramework-WebChat/issues/5857), in PR [#XXXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXXX), by [@OEvgeny](https://github.com/OEvgeny) +- Fixed the "Copied" confirmation of the copy button of code blocks disappearing too quickly: it is now shown for 5 seconds in all themes, like the Copy button of messages; the button is no longer an assertive live region, which announced "Copied" on top of its new name and "Copy" again when the confirmation ended, related to [#5857](https://github.com/microsoft/BotFramework-WebChat/issues/5857), in PR [#5892](https://github.com/microsoft/BotFramework-WebChat/pull/5892), by [@OEvgeny](https://github.com/OEvgeny) ## [4.19.2] - 2026-10-08 From ab7244cd185e254b166e1df1d665a0b288df0242 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Sat, 17 Oct 2026 18:28:37 -0700 Subject: [PATCH 4/4] Address review: useAutoResetState(), InferReactProps, property hook returns an array - Rename useCopiedState() to useAutoResetState(initialValue, resetIntervalInMS, ponyfill), returning [value, setValue]; the value is reset after the interval, setting it again restarts the interval - Both copy buttons keep their 5 second confirmation via COPIED_DURATION - useReactCodeBlockCopyButtonClass() returns a frozen [class] - CodeBlockCopyButtonProps uses InferReactProps - behavior.html: assert name, role, and no live/pressed directly from getAccessibilityNode() --- .../codeBlockCopyButton/behavior.html | 21 +++------- .../Text/private/ActivityCopyButton.tsx | 10 +++-- .../src/hooks/internal/useAutoResetState.ts | 39 +++++++++++++++++++ .../src/hooks/internal/useCopiedState.ts | 32 --------------- .../CustomElements/CustomElementsComposer.tsx | 2 +- .../customElements/CodeBlockCopyButton.tsx | 21 +++++----- 6 files changed, 64 insertions(+), 61 deletions(-) create mode 100644 packages/component/src/hooks/internal/useAutoResetState.ts delete mode 100644 packages/component/src/hooks/internal/useCopiedState.ts diff --git a/__tests__/html2/markdown/codeBlockCopyButton/behavior.html b/__tests__/html2/markdown/codeBlockCopyButton/behavior.html index b0dc049abd..41fc44c09e 100644 --- a/__tests__/html2/markdown/codeBlockCopyButton/behavior.html +++ b/__tests__/html2/markdown/codeBlockCopyButton/behavior.html @@ -88,19 +88,6 @@ const copyButton = document.querySelector(`[data-testid="${testIds.codeBlockCopyButton}"]`); - // What a screen reader gets from the button: its name and role, and whether it is a live region or a toggle button. - const getAccessibilityState = async () => { - const { name, properties = [], role } = await testHelpers.getAccessibilityNode(copyButton); - - return Object.fromEntries([ - ['name', name?.value], - ['role', role?.value], - ...properties - .filter(({ name }) => name === 'live' || name === 'pressed') - .map(({ name, value }) => [name, value.value]) - ]); - }; - const hideAndShowWebChat = () => { document.getElementById('webchat').style.display = 'none'; document.body.offsetWidth; // Need for browser to refresh the layout. @@ -130,7 +117,12 @@ // THEN: "Copied" should still be shown. // It should be a plain button: not a toggle button, and not a live region, which would raise another event for "Copied" and an assertive "Copy" later. - expect(await getAccessibilityState()).toEqual({ name: 'Copied', role: 'button' }); + const { name, properties, role } = await testHelpers.getAccessibilityNode(copyButton); + + expect(name.value).toBe('Copied'); + expect(role.value).toBe('button'); + expect(properties.map(property => property.name)).not.toContain('live'); + expect(properties.map(property => property.name)).not.toContain('pressed'); // WHEN: Paste into the paste box. await host.click(document.querySelector('[data-testid="paste box"]')); @@ -152,7 +144,6 @@ // THEN: The "Copy" button should be back to normal. await waitFor(async () => expect(await testHelpers.getAccessibleName(copyButton)).toBe('Copy')); - expect(await getAccessibilityState()).toEqual({ name: 'Copy', role: 'button' }); // WHEN: Hiding Web Chat and showing it back. await host.click(document.querySelector(`[data-testid="${testIds.sendBoxTextBox}"]`)); diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx index 3d9d461baa..4da7f2873d 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx @@ -5,7 +5,7 @@ import cx from 'classnames'; import React, { memo, useCallback, useEffect, useState } from 'react'; import { instance, nullable, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import useCopiedState from '../../../hooks/internal/useCopiedState'; +import useAutoResetState from '../../../hooks/internal/useAutoResetState'; import { useQueueStaticElement } from '../../../providers/LiveRegionTwin'; import refObject from '../../../types/internal/refObject'; import ActivityButton from './ActivityButton'; @@ -14,6 +14,8 @@ import styles from './ActivityCopyButton.module.css'; const { useLocalizer, usePonyfill, useUIState } = hooks; +const COPIED_DURATION = 5_000; + const activityCopyButtonPropsSchema = pipe( object({ className: optional(string()), @@ -29,12 +31,12 @@ function ActivityCopyButton(props: ActivityCopyButtonProps) { const [permissionGranted, setPermissionGranted] = useState(false); const [ponyfill] = usePonyfill(); + const [copied, setCopied] = useAutoResetState(false, COPIED_DURATION, ponyfill); const [uiState] = useUIState(); const classNames = useStyles(styles); const localize = useLocalizer(); const queueStaticElement = useQueueStaticElement(); - const [copied, markAsCopied] = useCopiedState(ponyfill); const copiedText = localize('COPY_BUTTON_COPIED_TEXT'); const copyText = localize('COPY_BUTTON_TEXT'); const disabled = !permissionGranted || uiState === 'disabled'; @@ -53,10 +55,10 @@ function ActivityCopyButton(props: ActivityCopyButtonProps) { .catch(error => console.error(`botframework-webchat-fluent-theme: Failed to copy to clipboard.`, error)); // Activating again restarts the confirmation. - markAsCopied(); + setCopied(true); queueStaticElement(
{copiedText}
); - }, [classNames, copiedText, markAsCopied, queueStaticElement, targetRef]); + }, [classNames, copiedText, queueStaticElement, setCopied, targetRef]); useEffect(() => { let unmounted = false; diff --git a/packages/component/src/hooks/internal/useAutoResetState.ts b/packages/component/src/hooks/internal/useAutoResetState.ts new file mode 100644 index 0000000000..6bce578676 --- /dev/null +++ b/packages/component/src/hooks/internal/useAutoResetState.ts @@ -0,0 +1,39 @@ +import { type GlobalScopePonyfill } from 'botframework-webchat-core'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRefFrom } from 'use-ref-from'; + +/** + * Like `useState()`, but the value is reset to `initialValue` after `resetIntervalInMS` milliseconds. + * + * Setting the value again restarts the interval. + * + * The ponyfill is passed in, so components rendered outside of the React tree of Web Chat (custom elements) can use it. + * + * @returns A tuple of the current value, and the function to set it. + */ +export default function useAutoResetState( + initialValue: T, + resetIntervalInMS: number, + { clearTimeout, setTimeout }: Pick +): readonly [T, (value: T) => void] { + const [value, setValue] = useState(initialValue); + const initialValueRef = useRefFrom(initialValue); + const resetTimeoutRef = useRef | undefined>(); + + // Values are wrapped in functions, so a function value is not called as a state updater. + const setValueWithAutoReset = useCallback( + (nextValue: T) => { + resetTimeoutRef.current && clearTimeout(resetTimeoutRef.current); + resetTimeoutRef.current = setTimeout(() => setValue(() => initialValueRef.current), resetIntervalInMS); + setValue(() => nextValue); + }, + [clearTimeout, initialValueRef, resetIntervalInMS, resetTimeoutRef, setTimeout, setValue] + ); + + useEffect( + () => () => resetTimeoutRef.current && clearTimeout(resetTimeoutRef.current), + [clearTimeout, resetTimeoutRef] + ); + + return useMemo(() => Object.freeze([value, setValueWithAutoReset] as const), [setValueWithAutoReset, value]); +} diff --git a/packages/component/src/hooks/internal/useCopiedState.ts b/packages/component/src/hooks/internal/useCopiedState.ts deleted file mode 100644 index c4feb003c4..0000000000 --- a/packages/component/src/hooks/internal/useCopiedState.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type GlobalScopePonyfill } from 'botframework-webchat-core'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -const COPIED_DURATION = 5_000; - -/** - * State of the "Copied" confirmation of a copy button: it is shown for 5 seconds after `markAsCopied()`, calling it again restarts the 5 seconds. - * - * The ponyfill is passed in, so copy buttons rendered outside of the React tree of Web Chat (custom elements) can use it. - * - * @returns A tuple of whether the confirmation should be shown, and the function to call after copying. - */ -export default function useCopiedState({ - clearTimeout, - setTimeout -}: Pick): readonly [boolean, () => void] { - const [copied, setCopied] = useState(false); - const copiedTimeoutRef = useRef | undefined>(); - - const markAsCopied = useCallback(() => { - copiedTimeoutRef.current && clearTimeout(copiedTimeoutRef.current); - copiedTimeoutRef.current = setTimeout(() => setCopied(false), COPIED_DURATION); - setCopied(true); - }, [clearTimeout, copiedTimeoutRef, setCopied, setTimeout]); - - useEffect( - () => () => copiedTimeoutRef.current && clearTimeout(copiedTimeoutRef.current), - [clearTimeout, copiedTimeoutRef] - ); - - return useMemo(() => Object.freeze([copied, markAsCopied] as const), [copied, markAsCopied]); -} diff --git a/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx b/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx index f4c52efab2..ba204f8f45 100644 --- a/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx +++ b/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx @@ -35,7 +35,7 @@ const CustomElementsComposer = ({ children }: CustomElementsComposerProps) => { [hash] ); - const CodeBlockCopyButtonClass = useReactCodeBlockCopyButtonClass(); + const [CodeBlockCopyButtonClass] = useReactCodeBlockCopyButtonClass(); const codeBlockCopyButtonTagName = useMemo( () => registerCustomElement('code-block-copy-button', CodeBlockCopyButtonClass), diff --git a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx index c83e48be49..97629f5e14 100644 --- a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx +++ b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx @@ -1,4 +1,4 @@ -import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; import { useStyles } from '@msinternal/botframework-webchat-styles/react'; import { hooks } from 'botframework-webchat-api'; import { type GlobalScopePonyfill } from 'botframework-webchat-core'; @@ -6,9 +6,9 @@ import cx from 'classnames'; import React, { memo, useCallback, useMemo } from 'react'; import { useRefFrom } from 'use-ref-from'; import { useStateWithRef } from 'use-state-with-ref'; -import { custom, function_, is, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; +import { custom, function_, is, object, optional, pipe, readonly, string } from 'valibot'; -import useCopiedState from '../../../hooks/internal/useCopiedState'; +import useAutoResetState from '../../../hooks/internal/useAutoResetState'; import { ComponentIcon } from '../../../Icon'; import testIds from '../../../testIds'; import wrapAsCustomElement from './wrapAsCustomElement'; @@ -17,6 +17,9 @@ import styles from './CodeBlockCopyButton.module.css'; const { usePonyfill } = hooks; +// Same as the Copy button of activities. +const COPIED_DURATION = 5_000; + const codeBlockCopyButtonPropsSchema = pipe( object({ className: optional(string()), @@ -30,7 +33,7 @@ const codeBlockCopyButtonPropsSchema = pipe( readonly() ); -type CodeBlockCopyButtonProps = InferInput; +type CodeBlockCopyButtonProps = InferReactProps; function CodeBlockCopyButton(props: CodeBlockCopyButtonProps) { const { @@ -41,7 +44,7 @@ function CodeBlockCopyButton(props: CodeBlockCopyButtonProps) { ponyfill } = validateProps(codeBlockCopyButtonPropsSchema, props); - const [copied, markAsCopied] = useCopiedState(ponyfill); + const [copied, setCopied] = useAutoResetState(false, COPIED_DURATION, ponyfill); const [disabled, setDisabled, disabledRef] = useStateWithRef(false); const classNames = useStyles(styles); const valueRef = useRefFrom(value); @@ -64,7 +67,7 @@ function CodeBlockCopyButton(props: CodeBlockCopyButtonProps) { ]); // Copying again restarts the confirmation. - obsoleted || markAsCopied(); + obsoleted || setCopied(true); } else if (state === 'denied') { setDisabled(true); } @@ -77,7 +80,7 @@ function CodeBlockCopyButton(props: CodeBlockCopyButtonProps) { return () => { obsoleted = true; }; - }, [disabledRef, markAsCopied, setDisabled, valueRef]); + }, [disabledRef, setCopied, setDisabled, valueRef]); // A plain button, like the copy button of activities: its name is "Copied" while the confirmation is shown. // A live region on the button would raise another event for "Copied", then an assertive "Copy" when the confirmation ends. @@ -131,10 +134,10 @@ function createCodeBlockCopyButtonClass(ponyfill: CodeBlockCopyButtonProps['pony * * The custom element renders in its own React root, outside of the React tree of Web Chat, so the ponyfill is passed down. */ -export default function useReactCodeBlockCopyButtonClass(): typeof HTMLElement { +export default function useReactCodeBlockCopyButtonClass(): readonly [typeof HTMLElement] { const [ponyfill] = usePonyfill(); - return useMemo(() => createCodeBlockCopyButtonClass(ponyfill), [ponyfill]); + return useMemo(() => Object.freeze([createCodeBlockCopyButtonClass(ponyfill)] as const), [ponyfill]); } export { codeBlockCopyButtonPropsSchema, type CodeBlockCopyButtonProps };