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..e0b4b739ac 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}"]`)); @@ -69,14 +69,23 @@ // THEN: The "Copy" button should say "Copied". await host.snapshot('local'); - // WHEN: After 1 second. + // 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(4_100); + // THEN: The "Copy" button should back to normal. 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 +94,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 6ad3bf96d5..0437e0ff45 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}"]`); @@ -101,6 +100,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(1000); + + // 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 +145,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(5100); + + // 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..2f393764b9 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,15 @@ 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 +85,16 @@ 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 +111,26 @@ 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': {