From 9348ec2644dfa3e721c5263f05e9545d4cb9d27e Mon Sep 17 00:00:00 2001 From: Ahona Salsabil Date: Thu, 1 Oct 2026 22:13:40 -0700 Subject: [PATCH 1/4] fix: retain copy confirmation for accessibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 068c5ef2-5e03-4d29-8666-f2b34b1bcc92 --- CHANGELOG.md | 1 + .../copyButton/behavior.hideAndShow.html | 4 +- __tests__/html2/copyButton/behavior.html | 44 ++++++++++- .../private/ActivityCopyButton.module.css | 9 ++- .../Text/private/ActivityCopyButton.tsx | 75 +++++++++++++++---- .../src/Styles/StyleSet/ActivityCopyButton.ts | 2 +- 6 files changed, 113 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3304cea8fe..0c35ec67f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Legends: ### Fixed +- Fixed Copy confirmation visibility and status announcement retention, resolving [#5857](https://github.com/microsoft/BotFramework-WebChat/issues/5857) - Fixed an error when a failed activity is present when Web Chat mounts, resolving [#5812](https://github.com/microsoft/BotFramework-WebChat/issues/5812), in PR [#5848](https://github.com/microsoft/BotFramework-WebChat/pull/5848), by [@OEvgeny](https://github.com/OEvgeny) ## [4.19.1] - 2026-06-09 diff --git a/__tests__/html2/copyButton/behavior.hideAndShow.html b/__tests__/html2/copyButton/behavior.hideAndShow.html index 15b4534eb5..0a86be1235 100644 --- a/__tests__/html2/copyButton/behavior.hideAndShow.html +++ b/__tests__/html2/copyButton/behavior.hideAndShow.html @@ -69,8 +69,8 @@ // THEN: The "Copy" button should say "Copied". await host.snapshot('local'); - // WHEN: After 1 second. - await testHelpers.sleep(1_000); + // WHEN: After the confirmation finishes. + await testHelpers.sleep(5_100); // THEN: The "Copy" button should back to normal. await host.snapshot('local'); diff --git a/__tests__/html2/copyButton/behavior.html b/__tests__/html2/copyButton/behavior.html index 6ad3bf96d5..852f02ef1b 100644 --- a/__tests__/html2/copyButton/behavior.html +++ b/__tests__/html2/copyButton/behavior.html @@ -101,6 +101,42 @@ // THEN: The copy button should change to "Copied". await host.snapshot('local'); + const copiedTextElement = copyButton.lastElementChild; + const copyStatus = document.querySelector('.webchat > [role="status"]'); + const firstCopyAnnouncement = copyStatus?.firstElementChild; + + // WHEN: Waiting while the confirmation should remain perceivable. + await testHelpers.sleep(1_000); + + // THEN: The visual and assistive technology confirmations should still be available. + expect({ + ariaAtomic: copyStatus?.getAttribute('aria-atomic'), + labelledAncestor: copyStatus?.closest('[aria-labelledby]'), + nestedInSharedLiveRegion: document.querySelector('.webchat__live-region').contains(copyStatus), + role: copyStatus?.getAttribute('role'), + statusText: copyStatus?.textContent, + visibility: getComputedStyle(copiedTextElement).visibility + }).toEqual({ + ariaAtomic: 'true', + labelledAncestor: null, + nestedInSharedLiveRegion: false, + role: 'status', + statusText: 'Copied', + visibility: 'visible' + }); + + // WHEN: Activating Copy again. + await host.sendKeys('ENTER'); + + // THEN: The status content should be replaced and the visual confirmation should restart. + expect(document.querySelector('.webchat > [role="status"]')).toBe(copyStatus); + expect(copyStatus.firstElementChild).not.toBe(firstCopyAnnouncement); + expect(copyStatus.textContent).toBe('Copied'); + expect(getComputedStyle(copiedTextElement).visibility).toBe('visible'); + + // WHEN: Disabling the visual animation before it completes. + copiedTextElement.style.animation = 'none'; + // WHEN: Paste into plain text and rich text text box. await host.click(document.querySelector('[data-testid="plain-text-box"]')); await host.sendKeys('+CONTROL', 'v', '-CONTROL'); @@ -110,8 +146,12 @@ await host.click(document.querySelector(`[data-testid="${WebChat.testIds.sendBoxTextBox}"]`)); - // Sleep for 1 second for the "Copied" text to go away. - await testHelpers.sleep(500); + // WHEN: Waiting for the restarted confirmation to finish. + await testHelpers.sleep(5_100); + + // THEN: The visual and assistive technology confirmations should be cleared without an animation event. + expect(getComputedStyle(copiedTextElement).visibility).toBe('hidden'); + expect(copyStatus.textContent).toBe(''); window.checkAccessibility = checkAccessibility; diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.module.css b/packages/component/src/Attachment/Text/private/ActivityCopyButton.module.css index 9441d3fc6e..ade6f10245 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.module.css +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.module.css @@ -19,7 +19,7 @@ &.activity-copy-button--copied { .activity-copy-button__copied-text { - animation: webchat__activity-copy-button__copied-animation 0.7s linear; + animation: webchat__activity-copy-button__copied-animation 5s linear; } .activity-button__icon, @@ -30,7 +30,14 @@ } :global(.webchat) .activity-copy-button__copy-announcement { + color: transparent; + height: 1px; + overflow: hidden; pointer-events: none; + position: absolute; + top: 0; + white-space: nowrap; + width: 1px; } @keyframes webchat__activity-copy-button__copied-animation { diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx index 5656f96304..de8d994253 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx @@ -3,15 +3,17 @@ 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 { createPortal } from 'react-dom'; import { instance, nullable, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import { useQueueStaticElement } from '../../../providers/LiveRegionTwin'; import refObject from '../../../types/internal/refObject'; import ActivityButton from './ActivityButton'; import styles from './ActivityCopyButton.module.css'; -const { useLocalizer, useUIState } = hooks; +const { useLocalizer, usePonyfill, useUIState } = hooks; + +const COPY_CONFIRMATION_DURATION = 5_000; const activityCopyButtonPropsSchema = pipe( object({ @@ -27,11 +29,14 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => { const { className, targetRef } = validateProps(activityCopyButtonPropsSchema, props); const classNames = useStyles(styles); + const [copyAnnouncementKey, setCopyAnnouncementKey] = useState(); + const [copyStatusPortalTarget, setCopyStatusPortalTarget] = useState(null); const [permissionGranted, setPermissionGranted] = useState(false); const [uiState] = useUIState(); + const [{ clearTimeout, setTimeout }] = usePonyfill(); const buttonRef = useRef(null); + const copyAnnouncementTimeoutIdRef = useRef>(); const localize = useLocalizer(); - const queueStaticElement = useQueueStaticElement(); const copiedText = localize('COPY_BUTTON_COPIED_TEXT'); const copyText = localize('COPY_BUTTON_TEXT'); @@ -50,6 +55,16 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => { } }, [buttonRef, classNames]); + useEffect(() => { + setCopyStatusPortalTarget(buttonRef.current?.closest('.webchat') || null); + }, [buttonRef, setCopyStatusPortalTarget]); + + useEffect( + () => () => + copyAnnouncementTimeoutIdRef.current && clearTimeout(copyAnnouncementTimeoutIdRef.current), + [clearTimeout, copyAnnouncementTimeoutIdRef] + ); + const handleClick = useCallback(() => { const htmlText = targetRef.current?.outerHTML; const plainText = targetRef.current?.textContent; @@ -71,8 +86,23 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => { buttonRef.current?.classList.add(...classNames['activity-copy-button--copied'].split(/\s+/gu)); - queueStaticElement(
{copiedText}
); - }, [classNames, copiedText, queueStaticElement, targetRef]); + setCopyAnnouncementKey(key => (key || 0) + 1); + + copyAnnouncementTimeoutIdRef.current && clearTimeout(copyAnnouncementTimeoutIdRef.current); + copyAnnouncementTimeoutIdRef.current = setTimeout(() => { + copyAnnouncementTimeoutIdRef.current = undefined; + + buttonRef.current?.classList.remove(...classNames['activity-copy-button--copied'].split(/\s+/gu)); + setCopyAnnouncementKey(undefined); + }, COPY_CONFIRMATION_DURATION); + }, [ + classNames, + clearTimeout, + copyAnnouncementTimeoutIdRef, + setCopyAnnouncementKey, + setTimeout, + targetRef + ]); useEffect(() => { let unmounted = false; @@ -89,17 +119,30 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => { }, [setPermissionGranted]); return ( - - {copiedText} - + <> + + {copiedText} + + {copyStatusPortalTarget && + createPortal( +
+ {!!copyAnnouncementKey && {copiedText}} +
, + copyStatusPortalTarget + )} + ); }; diff --git a/packages/component/src/Styles/StyleSet/ActivityCopyButton.ts b/packages/component/src/Styles/StyleSet/ActivityCopyButton.ts index b3a48ffbf5..27ff40da83 100644 --- a/packages/component/src/Styles/StyleSet/ActivityCopyButton.ts +++ b/packages/component/src/Styles/StyleSet/ActivityCopyButton.ts @@ -17,7 +17,7 @@ export default function createActivityButtonStyle() { '&.webchat__activity-copy-button--copied': { '.webchat__activity-copy-button__copied-text': { - animation: 'webchat__activity-copy-button__copied-animation 0.7s linear' + animation: 'webchat__activity-copy-button__copied-animation 5s linear' }, '.webchat__activity-button__icon, .webchat__activity-button__text': { From 1a44c3d57280174a96b01d5e7f346b31bb7c9c81 Mon Sep 17 00:00:00 2001 From: Ahona Salsabil Date: Thu, 1 Oct 2026 23:19:26 -0700 Subject: [PATCH 2/4] chore: format copy confirmation accessibility changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 068c5ef2-5e03-4d29-8666-f2b34b1bcc92 --- .../copyButton/behavior.hideAndShow.html | 9 ++++---- __tests__/html2/copyButton/behavior.html | 5 ++--- .../Text/private/ActivityCopyButton.tsx | 22 +++++-------------- 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/__tests__/html2/copyButton/behavior.hideAndShow.html b/__tests__/html2/copyButton/behavior.hideAndShow.html index 0a86be1235..c5abec35ca 100644 --- a/__tests__/html2/copyButton/behavior.hideAndShow.html +++ b/__tests__/html2/copyButton/behavior.hideAndShow.html @@ -60,8 +60,8 @@ ); // axe-core is crazy about empty button name while swapping name - const { checkAccessibility } = window - window.checkAccessibility = async () => { }; + const { checkAccessibility } = window; + window.checkAccessibility = async () => {}; // WHEN: Focus on the "Copy" button via keyboard. await host.click(document.querySelector(`[data-testid="${WebChat.testIds.copyButton}"]`)); @@ -76,7 +76,7 @@ await host.snapshot('local'); window.checkAccessibility = checkAccessibility; - + // WHEN: Hiding Web Chat and showing it back. document.getElementById('webchat').style.display = 'none'; document.body.offsetWidth; // Need for browser to refresh the layout. @@ -85,9 +85,8 @@ // THEN: The "Copy" button should kept at normal. await host.snapshot('local'); - expect(document.querySelector(`[data-testid="${WebChat.testIds.copyButton}"]`).innerText).toBe('Copy') + expect(document.querySelector(`[data-testid="${WebChat.testIds.copyButton}"]`).innerText).toBe('Copy'); }); - diff --git a/__tests__/html2/copyButton/behavior.html b/__tests__/html2/copyButton/behavior.html index 852f02ef1b..3d16f93f60 100644 --- a/__tests__/html2/copyButton/behavior.html +++ b/__tests__/html2/copyButton/behavior.html @@ -84,10 +84,9 @@ await host.sendShiftTab(3); await host.sendKeys('ENTER'); - // axe-core is crazy about empty button name while swapping name - const { checkAccessibility } = window - window.checkAccessibility = async () => { }; + const { checkAccessibility } = window; + window.checkAccessibility = async () => {}; // THEN: Should focus on the "Copy" button const copyButton = document.querySelector(`[data-testid="${WebChat.testIds.copyButton}"]`); diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx index de8d994253..2f393764b9 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx @@ -60,8 +60,7 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => { }, [buttonRef, setCopyStatusPortalTarget]); useEffect( - () => () => - copyAnnouncementTimeoutIdRef.current && clearTimeout(copyAnnouncementTimeoutIdRef.current), + () => () => copyAnnouncementTimeoutIdRef.current && clearTimeout(copyAnnouncementTimeoutIdRef.current), [clearTimeout, copyAnnouncementTimeoutIdRef] ); @@ -95,14 +94,7 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => { buttonRef.current?.classList.remove(...classNames['activity-copy-button--copied'].split(/\s+/gu)); setCopyAnnouncementKey(undefined); }, COPY_CONFIRMATION_DURATION); - }, [ - classNames, - clearTimeout, - copyAnnouncementTimeoutIdRef, - setCopyAnnouncementKey, - setTimeout, - targetRef - ]); + }, [classNames, clearTimeout, copyAnnouncementTimeoutIdRef, setCopyAnnouncementKey, setTimeout, targetRef]); useEffect(() => { let unmounted = false; @@ -119,7 +111,7 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => { }, [setPermissionGranted]); return ( - <> + { {copyStatusPortalTarget && createPortal( -
+
{!!copyAnnouncementKey && {copiedText}}
, copyStatusPortalTarget )} - + ); }; From 2d444e04c10b58f7ade1e8e246605a1e511154f3 Mon Sep 17 00:00:00 2001 From: Ahona Salsabil Date: Thu, 1 Oct 2026 23:20:31 -0700 Subject: [PATCH 3/4] test: assert copy confirmation duration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 068c5ef2-5e03-4d29-8666-f2b34b1bcc92 --- __tests__/html2/copyButton/behavior.hideAndShow.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/__tests__/html2/copyButton/behavior.hideAndShow.html b/__tests__/html2/copyButton/behavior.hideAndShow.html index c5abec35ca..e0b4b739ac 100644 --- a/__tests__/html2/copyButton/behavior.hideAndShow.html +++ b/__tests__/html2/copyButton/behavior.hideAndShow.html @@ -69,8 +69,17 @@ // THEN: The "Copy" button should say "Copied". await host.snapshot('local'); + // WHEN: Waiting while the confirmation should remain perceivable. + await testHelpers.sleep(1_000); + + // THEN: The "Copied" confirmation should remain visible. + expect( + getComputedStyle(document.querySelector(`[data-testid="${WebChat.testIds.copyButton}"]`).lastElementChild) + .visibility + ).toBe('visible'); + // WHEN: After the confirmation finishes. - await testHelpers.sleep(5_100); + await testHelpers.sleep(4_100); // THEN: The "Copy" button should back to normal. await host.snapshot('local'); From fbdede960c67ea41dcd73ebc2e724251e5dfd1a7 Mon Sep 17 00:00:00 2001 From: Ahona Salsabil Date: Fri, 2 Oct 2026 00:15:35 -0700 Subject: [PATCH 4/4] test: support copy test Babel version Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 068c5ef2-5e03-4d29-8666-f2b34b1bcc92 --- __tests__/html2/copyButton/behavior.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/html2/copyButton/behavior.html b/__tests__/html2/copyButton/behavior.html index 3d16f93f60..0437e0ff45 100644 --- a/__tests__/html2/copyButton/behavior.html +++ b/__tests__/html2/copyButton/behavior.html @@ -105,7 +105,7 @@ const firstCopyAnnouncement = copyStatus?.firstElementChild; // WHEN: Waiting while the confirmation should remain perceivable. - await testHelpers.sleep(1_000); + await testHelpers.sleep(1000); // THEN: The visual and assistive technology confirmations should still be available. expect({ @@ -146,7 +146,7 @@ await host.click(document.querySelector(`[data-testid="${WebChat.testIds.sendBoxTextBox}"]`)); // WHEN: Waiting for the restarted confirmation to finish. - await testHelpers.sleep(5_100); + await testHelpers.sleep(5100); // THEN: The visual and assistive technology confirmations should be cleared without an animation event. expect(getComputedStyle(copiedTextElement).visibility).toBe('hidden');