Skip to content

Show the code block copy confirmation for 5 seconds, like the message Copy button - #5892

Open
Eugene (OEvgeny) wants to merge 4 commits into
mainfrom
OEvgeny/fix-code-block-copy-confirmation
Open

Eugene (OEvgeny) wants to merge 4 commits into
mainfrom
OEvgeny/fix-code-block-copy-confirmation

Conversation

@OEvgeny

@OEvgeny Eugene (OEvgeny) commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #5857: the "Copied" confirmation of the copy button of code blocks disappears after half a second.

Changelog Entry

Fixed

  • 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, in PR #5892, by @OEvgeny

Description

#5872 fixed the Copy button of messages. The copy button of code blocks had the same problem: its checkmark and its "Copied" name were driven by a 0.5 second CSS animation, in the default, Fluent, and Copilot themes. It also had aria-live="assertive" and aria-pressed on the button itself. In Edge, each press raised a live region event and a name change for "Copied", then a live region event and a name change for "Copy" when the animation ended.

The confirmation now uses the same timer as the Copy button of messages: 5 seconds, restarted when copying again, not affected by hiding Web Chat. The button is a plain button: its name is "Copied" while confirmed, and the focus indicator stays visible, as before.

Measured in Edge 155:

Before After
Checkmark and "Copied" name ~0.5 s in all themes 5.0 s in all themes
Button semantics while confirmed assertive live region, toggle button (pressed) button
Screen reader events per press live region "Copied" + name "Copied", then live region "Copy" + name "Copy" name "Copied", then name "Copy" after 5 s
Hiding Web Chat while confirmed replays the animation no effect

Design

The 5 second timer of the Copy button of messages is moved into a shared internal hook, useAutoResetState(initialValue, resetIntervalInMS, ponyfill), used by both buttons. It works like useState(), but the value goes back to initialValue after the interval, and setting it again restarts the interval. The ponyfill is a parameter because the code block copy button is a custom element rendered in its own React root, so it cannot call usePonyfill(). CustomElementsComposer now creates its class with useReactCodeBlockCopyButtonClass(), which returns [class] and passes the ponyfill down. This mirrors useReactCodeBlockClass(), which passes the code highlighter and the localized strings to the code block. With the ponyfill, the confirmation follows the host's clock, and tests can use a fake clock.

aria-live is removed from the button. "Copied" reaches screen readers through the name of the button, which is spoken while the button has the focus, such as after pressing it with the keyboard (see the open question for mouse clicks in Safari). The Copy button of messages uses this channel too, and also queues "Copied" to Web Chat's live region (see below). The live region on the button raised a second event for "Copied", and another for "Copy" when the confirmation ended. With a 5 second confirmation, that would be an assertive "Copy" 5 seconds after the press, interrupting whatever the user does next.

The shared live region that the Copy button of messages also uses is not used here, for two reasons:

  • The custom element's React root has no access to it.
  • The same button is used in the "View code" dialog, which is a modal <dialog>. Everything outside the dialog is inert. In Edge, a bot message added to the shared live region while the dialog was open raised no live region event.

aria-pressed is removed, so the button is not exposed as a toggle button, like the Copy button of messages.

Open question: in Safari, clicking a button does not focus it, so VoiceOver users who click with the mouse may not hear the name change. I have not verified whether VoiceOver announces the name change of the focused button. Before this change, the live region on the button covered that case. A host-independent announcement, such as ariaNotify(), would cover both copy buttons. #5872 already listed it as a separate change.

Follow-up after #5893 merges: behavior.highlighted.html will move to the fake clock too. With a fake clock the message arrives before Shiki has loaded, and only #5893's highlighter fix keeps that code block from staying empty.

Specific Changes

  • Added hooks/internal/useAutoResetState.ts: the timer of the 5 second "Copied" state, extracted from ActivityCopyButton and made general: [value, setValue] that resets after an interval. Both copy buttons use it with false and 5 seconds

  • CodeBlockCopyButton.tsx: uses useAutoResetState(); removed aria-live, aria-pressed and the animationend handler; props validated with valibot and typed with InferReactProps; the default export is now useReactCodeBlockCopyButtonClass(), which returns [class] and passes the ponyfill to the custom element. The global class code-block-copy-button--copied is still set while confirmed, for host styling, like activity-copy-button--copied

  • CodeBlockCopyButton.module.css: removed the 0.5 second animation, and the opacity: 0 of the element that carries the "Copied" name: it is empty, so the rule only mattered for the animation

  • CustomElementsComposer.tsx: registers the class from useReactCodeBlockCopyButtonClass()

  • __tests__/html2/markdown/codeBlockCopyButton/behavior.html: fake clock via ponyfill. It asserts "Copied" at 4.999 seconds and "Copy" at 5 seconds, that hiding Web Chat does not change it, the pasted code, and, with getAccessibilityNode(), the button's name and role without a live region or pressed state. The snapshots are unchanged.

  • adaptiveCards/behavior.html: fake clock; wait for "Copied", advance the clock by 5 seconds, and wait for "Copy", instead of a 500 ms sleep and an unstable snapshot. behavior.highlighted.html keeps the real clock until Ensure 4.5:1 contrast for syntax-highlighted code and keep code blocks when messages are already in the store #5893 is merged. behavior.highlighted.html also waits for the Python code to be highlighted. The snapshots are unchanged.

  • The 3 tests stay JSX; they load @babel/standalone@7.26.5 (already used by copyButton/behavior.hideAndShow.html), whose data-type="module" lets them import waitFor().

  • I have added tests and executed them locally

  • I have updated CHANGELOG.md

  • I have updated documentation

Review Checklist

This section is for contributors to review your work.

  • Accessibility reviewed (tab order, content readability, alt text, color contrast)
  • Browser and platform compatibilities reviewed
  • CSS styles reviewed (minimal rules, no z-index)
  • Documents reviewed (docs, samples, live demo)
  • Internationalization reviewed (strings, unit formatting)
  • package.json and package-lock.json reviewed
  • Security reviewed (no data URIs, check for nonce leak)
  • Tests reviewed (coverage, legitimacy)

@OEvgeny
Eugene (OEvgeny) marked this pull request as ready for review October 17, 2026 15:19
Copilot AI balanced review requested due to automatic review settings October 17, 2026 15:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

WebChat: { ReactWebChat, testIds }
} = window; // Imports in UMD fashion.

// Using the real clock: with a fake clock, the message would arrive before the code highlighter is loaded.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, but still using a fake clock and then wait 5 seconds for "Copied -> Copy" is not a good idea.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The fake-clock version is ready, but with a fake clock the message renders before Shiki has loaded, and only #5893's highlighter fix keeps that code block from staying empty, so I'll push it right after #5893 merges.

Comment thread __tests__/html2/markdown/codeBlockCopyButton/behavior.html Outdated
readonly()
);

type CodeBlockCopyButtonProps = InferInput<typeof codeBlockCopyButtonPropsSchema>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a new inference helper.

Suggested change
type CodeBlockCopyButtonProps = InferInput<typeof codeBlockCopyButtonPropsSchema>;
type CodeBlockCopyButtonProps = InferReactProps<typeof codeBlockCopyButtonPropsSchema>;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, CodeBlockCopyButtonProps is now InferReactProps<typeof codeBlockCopyButtonPropsSchema>.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe rename this to useAutoResetState(resetIntervalInMS: number): readonly [...] so it become a bit more useful outside of copy button when needed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to useAutoResetState(initialValue, resetIntervalInMS, ponyfill): readonly [value, setValue]: like useState(), but the value goes back to initialValue after the interval and setting it again restarts it; both copy buttons call it with false and 5 seconds, and the ponyfill stays a parameter because the code block button renders in its own React root without usePonyfill().

*
* 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Property hook always return in array.

Suggested change
export default function useReactCodeBlockCopyButtonClass(): typeof HTMLElement {
export default function useReactCodeBlockCopyButtonClass(): readonly [typeof HTMLElement] {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, useReactCodeBlockCopyButtonClass() now returns a frozen [class] and CustomElementsComposer destructures it.

Eugene Olonov added 4 commits October 17, 2026 18:15
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.
…egion

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
…eturns 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()
@OEvgeny
Eugene (OEvgeny) force-pushed the OEvgeny/fix-code-block-copy-confirmation branch from 0da9947 to ab7244c Compare October 18, 2026 01:40

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants