Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 14 additions & 6 deletions __tests__/html2/copyButton/behavior.hideAndShow.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,32 @@
);

// 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}"]`));

// 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.
Expand All @@ -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');
});

</script>
</body>
</html>
49 changes: 44 additions & 5 deletions __tests__/html2/copyButton/behavior.html
Original file line number Diff line number Diff line change
Expand Up @@ -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}"]`);
Expand All @@ -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');
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -27,11 +29,14 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => {
const { className, targetRef } = validateProps(activityCopyButtonPropsSchema, props);

const classNames = useStyles(styles);
const [copyAnnouncementKey, setCopyAnnouncementKey] = useState<number>();
const [copyStatusPortalTarget, setCopyStatusPortalTarget] = useState<HTMLElement | null>(null);
const [permissionGranted, setPermissionGranted] = useState(false);
const [uiState] = useUIState();
const [{ clearTimeout, setTimeout }] = usePonyfill();
const buttonRef = useRef<HTMLButtonElement>(null);
const copyAnnouncementTimeoutIdRef = useRef<ReturnType<typeof setTimeout>>();
const localize = useLocalizer();
const queueStaticElement = useQueueStaticElement();

const copiedText = localize('COPY_BUTTON_COPIED_TEXT');
const copyText = localize('COPY_BUTTON_TEXT');
Expand All @@ -50,6 +55,15 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => {
}
}, [buttonRef, classNames]);

useEffect(() => {
setCopyStatusPortalTarget(buttonRef.current?.closest<HTMLElement>('.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;
Expand All @@ -71,8 +85,16 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => {

buttonRef.current?.classList.add(...classNames['activity-copy-button--copied'].split(/\s+/gu));

queueStaticElement(<div className={classNames['activity-copy-button__copy-announcement']}>{copiedText}</div>);
}, [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;
Expand All @@ -89,17 +111,26 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => {
}, [setPermissionGranted]);

return (
<ActivityButton
className={cx(classNames['activity-copy-button'], className)}
data-testid="copy button"
disabled={disabled}
icon="copy"
onClick={handleClick}
ref={buttonRef}
text={copyText}
>
<span className={classNames['activity-copy-button__copied-text']}>{copiedText}</span>
</ActivityButton>
<React.Fragment>
<ActivityButton
className={cx(classNames['activity-copy-button'], className)}
data-testid="copy button"
disabled={disabled}
icon="copy"
onClick={handleClick}
ref={buttonRef}
text={copyText}
>
<span className={classNames['activity-copy-button__copied-text']}>{copiedText}</span>
</ActivityButton>
{copyStatusPortalTarget &&
createPortal(
<div aria-atomic={true} className={classNames['activity-copy-button__copy-announcement']} role="status">
{!!copyAnnouncementKey && <span key={copyAnnouncementKey}>{copiedText}</span>}
</div>,
copyStatusPortalTarget
)}
</React.Fragment>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down