fix: Screen reader not announcing Adaptive Card content in stacked layout#5784
fix: Screen reader not announcing Adaptive Card content in stacked layout#5784uzirthapa wants to merge 14 commits intomicrosoft:mainfrom
Conversation
There was a problem hiding this comment.
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-labelfrom card text when none is provided (nospeak). - 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.
packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts
Show resolved
Hide resolved
106373c to
a7899ae
Compare
…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>
… overflow:hidden parent" This reverts commit e91b48d.
- 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>
a31fd5d to
7185a19
Compare
@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(); |
There was a problem hiding this comment.
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:
- Use AI to write that algorithm
- You will need to verify it, corner cases, etc. could be a pain
- See if we can reuse the algorithm from
axe-corepackage- 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-coreis MPL 2.0 while we are MIT
- 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.
Changelog Entry
speakproperty now derivearia-labelfrom text content so screen readers can announce card contentDescription
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
speakproperty had noaria-label.The Adaptive Cards SDK only sets
aria-labelwhen the card author explicitly provides aspeakproperty, which is uncommon. This left most cards invisible to screen readers.Design
The fix extends
useRoleModEffect(the existing Adaptive Card accessibility mod) to derivearia-labelfrom the card'stextContentwhen nospeakproperty is set:aria-label— if the card already has one (fromspeak), leave it alonetextContentfrom the card DOM, collapse whitespace, and set asaria-labelrole— cards with aspeak-providedaria-labeland form inputs getrole="form"; all others getrole="figure"to avoidlandmark-uniqueaxe violations with the send box<form>The fix uses the existing
setOrRemoveAttributeIfFalseWithUndoutility and theMutationObserver-based mod framework, so thearia-labelis durable across Adaptive Card re-renders.What changed
speak, text onlyaria-label,role="figure"aria-labelfrom text content,role="figure"speak, with inputsaria-label,role="figure"aria-labelfrom text content,role="figure"speak, text onlyaria-labelfromspeak,role="figure"speak, with inputsaria-labelfromspeak,role="form"Specific Changes
useRoleModEffect.ts— Derivearia-labelfrom cardtextContentwhenspeakis not set; only assignrole="form"when card has an originalaria-labelfrom thespeakpropertyCHANGELOG.md— Added fix entryhack.roleMod.ariaLabelFromTextContent.html— New e2e test verifyingaria-labelderivation for cards with and withoutspeakattachmentRow.focusable.html— New e2e test verifying attachment row a11y attributes and derivedaria-labelon cardsI have added tests and executed them locally
I have updated
CHANGELOG.mdI have updated documentation
Review Checklist
z-index)package.jsonandpackage-lock.jsonreviewed🤖 Generated with Claude Code