Skip to content

fix: Screen reader not announcing Adaptive Card content in stacked layout#5784

Open
uzirthapa wants to merge 14 commits intomicrosoft:mainfrom
uzirthapa:fix/adaptive-card-screen-reader-a11y
Open

fix: Screen reader not announcing Adaptive Card content in stacked layout#5784
uzirthapa wants to merge 14 commits intomicrosoft:mainfrom
uzirthapa:fix/adaptive-card-screen-reader-a11y

Conversation

@uzirthapa
Copy link
Copy Markdown

@uzirthapa uzirthapa commented Mar 26, 2026

Fixes ADO #32780246

Changelog Entry

  • Fixed screen reader (Narrator/NVDA) not announcing Adaptive Card content in stacked layout, by @uzirthapa
    • Adaptive Cards without speak property now derive aria-label from text content so screen readers can announce card content

Description

Screen readers (Narrator/NVDA) were not announcing Adaptive Card content in the Copilot Studio test chat. When a user navigated to an Adaptive Card, the screen reader had nothing to announce because cards without the speak property had no aria-label.

The Adaptive Cards SDK only sets aria-label when the card author explicitly provides a speak property, which is uncommon. This left most cards invisible to screen readers.

Design

The fix extends useRoleModEffect (the existing Adaptive Card accessibility mod) to derive aria-label from the card's textContent when no speak property is set:

  1. Check for existing aria-label — if the card already has one (from speak), leave it alone
  2. Derive from text content — extract textContent from the card DOM, collapse whitespace, and set as aria-label
  3. Set appropriate role — cards with a speak-provided aria-label and form inputs get role="form"; all others get role="figure" to avoid landmark-unique axe violations with the send box <form>

The fix uses the existing setOrRemoveAttributeIfFalseWithUndo utility and the MutationObserver-based mod framework, so the aria-label is durable across Adaptive Card re-renders.

What changed

Card type Before After
No speak, text only No aria-label, role="figure" aria-label from text content, role="figure"
No speak, with inputs No aria-label, role="figure" aria-label from text content, role="figure"
With speak, text only aria-label from speak, role="figure" Same (unchanged)
With speak, with inputs aria-label from speak, role="form" Same (unchanged)

Specific Changes

  • useRoleModEffect.ts — Derive aria-label from card textContent when speak is not set; only assign role="form" when card has an original aria-label from the speak property

  • CHANGELOG.md — Added fix entry

  • hack.roleMod.ariaLabelFromTextContent.html — New e2e test verifying aria-label derivation for cards with and without speak

  • attachmentRow.focusable.html — New e2e test verifying attachment row a11y attributes and derived aria-label on cards

  • 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)

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings March 26, 2026 22:43
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes an accessibility regression where screen readers were not announcing Adaptive Card content in the stacked activity layout by improving keyboard focus behavior and providing an accessible name fallback for Adaptive Cards without speak.

Changes:

  • Made stacked layout attachment rows keyboard-focusable and added focus indicator styling.
  • Extended Adaptive Card DOM “mod” logic to derive an aria-label from card text when none is provided (no speak).
  • Added a changelog entry describing the accessibility fix.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
packages/component/src/Activity/StackedLayout.module.css Adds focus/focus-visible styling for attachment rows.
packages/component/src/Activity/AttachmentRow.tsx Adds tabIndex={0} to make attachment rows reachable via keyboard.
packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts Adds derived aria-label fallback from card text content when missing.
CHANGELOG.md Documents the accessibility fix in the changelog.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@uzirthapa uzirthapa force-pushed the fix/adaptive-card-screen-reader-a11y branch 2 times, most recently from 106373c to a7899ae Compare March 30, 2026 16:45
uzirthapa and others added 13 commits April 1, 2026 17:32
…yout

Screen readers (Narrator/NVDA) were not announcing Adaptive Card content
when focused because stacked layout attachment rows lacked tabIndex and
cards without the `speak` property had no aria-label for screen readers
to announce.

- Add `tabIndex={0}` to stacked layout AttachmentRow for keyboard focus
- Add focus-visible styling using existing CSS custom properties
- Derive `aria-label` from card text content when `speak` is not set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…w:hidden parent

The stacked layout content area has overflow:hidden, which clips outlines
rendered outside the element box. Using a negative outline-offset renders
the focus ring inside the element, making it visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- hack.roleMod.ariaLabelFromTextContent.html: Tests that aria-label is
  derived from text content when speak is not set, and that speak value
  is used when present
- attachmentRow.focusable.html: Tests that stacked layout attachment
  rows have tabIndex="0" and are focusable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Prettier: break AttachmentRow props onto separate lines
- no-empty-function: use named noOp instead of inline empty arrow
- require-unicode-regexp: add 'u' flag to regex
- no-magic-numbers: extract ARIA_LABEL_MAX_LENGTH constant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove empty arrow noOp function that violated @typescript-eslint/no-empty-function
- Use optional chaining for undoAriaLabel call instead

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The useTextBox.html test fails with landmark-unique axe violation,
which appears to be a pre-existing flaky test unrelated to our changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tabIndex={0} added an extra tab stop inside the card's focus trap,
causing SHIFT-TAB from card controls to land on the attachment row
instead of the expected element. Using tabIndex={-1} makes the row
programmatically focusable (for screen readers via virtual cursor)
without adding it to the Tab order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cards without "speak" that have form inputs were getting role="form"
from the derived aria-label, causing landmark-unique axe violations
when the page also contains the send box <form>. Now only cards with
an explicit "speak" property get role="form".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ailures

Reverted tabIndex={-1} on AttachmentRow and focus CSS on
StackedLayout since they could interfere with existing focus traps
and cause test failures. The core fix (aria-label derivation from
text content in useRoleModEffect) is sufficient for screen readers
to announce Adaptive Card content via browse mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address PR review comment: say "text content" instead of
"visible text content" since textContent includes all text nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@uzirthapa uzirthapa force-pushed the fix/adaptive-card-screen-reader-a11y branch from a31fd5d to 7185a19 Compare April 1, 2026 17:32
@uzirthapa
Copy link
Copy Markdown
Author

@uzirthapa please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@microsoft-github-policy-service agree [company="{your company}"]

Options:

  • (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
@microsoft-github-policy-service agree
  • (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
@microsoft-github-policy-service agree company="Microsoft"

Contributor License Agreement

@microsoft-github-policy-service agree company="Microsoft"

Screen readers should announce all content in the Adaptive Card,
not a truncated version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
let undoAriaLabel: (() => void) | undefined;

if (!hasOriginalAriaLabel) {
const textContent = (cardElement.textContent || '').replace(/\s+/gu, ' ').trim();
Copy link
Copy Markdown
Contributor

@compulim compulim Apr 2, 2026

Choose a reason for hiding this comment

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

textContent isn't the appropriate way to get the accessible content out of DOM.

This is the right approach, https://www.w3.org/TR/accname-1.2/#computation-steps.

Some time ago, I was trying to do the same thing. 🤣

Right now, you can either:

  1. Use AI to write that algorithm
    • You will need to verify it, corner cases, etc. could be a pain
  2. See if we can reuse the algorithm from axe-core package
    • Industry proven
    • Not sure if it's componentized or publicly accessible
    • Need to make sure they don't add much to our bundle size (I think ~20 KiB is okay, too much if > 100 KiB)
    • Need to read the license, axe-core is MPL 2.0 while we are MIT
  3. Find if anyone building this on NPM

Also, you need to see if <button> is done properly, e.g. we probably want it to say, "Click me button", than just "Click me". That means we need to localize "button" and every ARIA roles. Then we might need to say "checkbox checked" (aria-checked) and kinda... then we might need to localize every ARIA attributes. Notably, aria-posinset (position in set) and aria-setsize need to be localized in pair ("checkbox, checked, 1 of 3"), etc.

Then... after this is implemented, you may find "checkbox, checked, 1 of 3" is Windows way of narrating stuff, and "1 of 3 checkboxes, checked" is macOS way (I forget which is which, but you got the idea). And people may say, "the narration is not uniform" or they may never discover this, kinda-kinda.

I am definitely not discouraging you working on this, I know too much of this. You will soon find out you are writing a non-small part of a screen reader. 🤣

p.s. I also looked at the old screen reader on Chromebook which is JS-based, but Google discontinued it long time ago and it's defunct.


The other way, just use screen reader to read the card... but if the card content is too complex, then screen reader bugged out (says, stop narrating in the middleware of the card, etc.) Then people complained it's a Web Chat issue than AC or screen reader issue...


That's why we stay status quo on rendering side. And instead, ask AC author to add speak property than Web Chat compute it for them. Adaptive Cards is more like a figure/image and alt-text for figure/image should be mandated.

p.s. I don't think speak is a good name and that's why people don't recognize the need of it, it should be alt or something, but I don't control AC schema.

Copy link
Copy Markdown
Contributor

@compulim compulim left a comment

Choose a reason for hiding this comment

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

See my comment.

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