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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ Breaking changes in this release:

### Fixed

- Fixed screen reader (Narrator/NVDA) not announcing Adaptive Card content in stacked layout, by [@uzirthapa](https://github.com/uzirthapa)
- Adaptive Cards without `speak` property now derive `aria-label` from visible text content so screen readers can announce card content
- Fixed [#5256](https://github.com/microsoft/BotFramework-WebChat/issues/5256). `styleOptions.maxMessageLength` should support any JavaScript number value including `Infinity`, by [@compulim](https://github.com/compulim), in PR [#5255](https://github.com/microsoft/BotFramework-WebChat/issues/pull/5255)
- Fixes [#4965](https://github.com/microsoft/BotFramework-WebChat/issues/4965). Removed keyboard helper screen in [#5234](https://github.com/microsoft/BotFramework-WebChat/pull/5234), by [@amirmursal](https://github.com/amirmursal) and [@OEvgeny](https://github.com/OEvgeny)
- Fixes [#5268](https://github.com/microsoft/BotFramework-WebChat/issues/5268). Concluded livestream is sealed and activities received afterwards are ignored, and `streamSequence` is not required in final activity, in PR [#5273](https://github.com/microsoft/BotFramework-WebChat/pull/5273), by [@compulim](https://github.com/compulim)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script>
run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();

WebChat.renderWebChat({ directLine, store }, document.getElementById('webchat'));

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({
attachments: [
{
content: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.5',
body: [
{
type: 'TextBlock',
text: 'First card content'
}
]
},
contentType: 'application/vnd.microsoft.card.adaptive'
}
]
});

await directLine.emulateIncomingActivity({
attachments: [
{
content: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.5',
body: [
{
type: 'TextBlock',
text: 'Second card content'
}
]
},
contentType: 'application/vnd.microsoft.card.adaptive'
}
]
});

await pageConditions.numActivitiesShown(2);

const attachmentRows = document.querySelectorAll('[aria-roledescription="attachment"]');

// Both attachment rows should exist with proper a11y attributes.
expect(attachmentRows).toHaveProperty('length', 2);
expect(attachmentRows[0].getAttribute('role')).toBe('group');
expect(attachmentRows[1].getAttribute('role')).toBe('group');

// The Adaptive Cards inside should have aria-labels derived from text content.
const firstCard = attachmentRows[0].querySelector('.ac-adaptiveCard');
const secondCard = attachmentRows[1].querySelector('.ac-adaptiveCard');

expect(firstCard.getAttribute('aria-label')).toContain('First card content');
expect(firstCard.getAttribute('role')).toBe('figure');

expect(secondCard.getAttribute('aria-label')).toContain('Second card content');
expect(secondCard.getAttribute('role')).toBe('figure');
});
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script>
run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();

WebChat.renderWebChat({ directLine, store }, document.getElementById('webchat'));

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({
attachments: [
{
content: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.5',
body: [
{
type: 'TextBlock',
text: 'Flight Status Update'
},
{
type: 'TextBlock',
text: 'Flight AA1234 from Seattle to New York'
}
]
},
contentType: 'application/vnd.microsoft.card.adaptive'
},
{
content: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.5',
speak: 'Custom speak text for screen readers',
body: [
{
type: 'TextBlock',
text: 'This text should not be the aria-label'
}
]
},
contentType: 'application/vnd.microsoft.card.adaptive'
},
{
content: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.5',
body: [
{
type: 'Input.Text',
id: 'name',
label: 'Your Name',
placeholder: 'Enter your name'
}
]
},
contentType: 'application/vnd.microsoft.card.adaptive'
}
]
});

await pageConditions.numActivitiesShown(1);

const [cardNoSpeak, cardWithSpeak, cardFormNoSpeak] = Array.from(
document.querySelectorAll('.ac-adaptiveCard')
);

// Card without speak: aria-label should be derived from visible text content.
expect(cardNoSpeak.getAttribute('aria-label')).toContain('Flight Status Update');
expect(cardNoSpeak.getAttribute('aria-label')).toContain('Flight AA1234');
expect(cardNoSpeak.getAttribute('role')).toBe('figure');

// Card with speak: aria-label should use the speak property value.
expect(cardWithSpeak.getAttribute('aria-label')).toBe('Custom speak text for screen readers');
expect(cardWithSpeak.getAttribute('role')).toBe('figure');

// Card with form inputs and no speak: aria-label should be derived from text content,
// but role should be "figure" (not "form") to avoid duplicate form landmarks on the page.
expect(cardFormNoSpeak.getAttribute('aria-label')).toBeTruthy();
expect(cardFormNoSpeak.getAttribute('role')).toBe('figure');
});
</script>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,43 @@ export default function useRoleModEffect(
adaptiveCard: AdaptiveCard
): readonly [(cardElement: HTMLElement) => void, () => void] {
const modder = useMemo(
() => (_, cardElement: HTMLElement) =>
setOrRemoveAttributeIfFalseWithUndo(
() => (_, cardElement: HTMLElement) => {
// Check if the card already has an aria-label from the "speak" property before we derive one.
const hasOriginalAriaLabel = !!cardElement.getAttribute('aria-label');

// If the card doesn't have an aria-label (i.e. no "speak" property was set),
// derive one from the card's text content so screen readers can announce it.
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.


if (textContent) {
undoAriaLabel = setOrRemoveAttributeIfFalseWithUndo(cardElement, 'aria-label', textContent);
}
}

// Only use role="form" when the card has an original aria-label (from "speak" property).
// Derived aria-labels should use role="figure" to avoid duplicate form landmarks
// when the page also contains the send box <form>.
const undoRole = setOrRemoveAttributeIfFalseWithUndo(
cardElement,
'role',
// "form" role requires either "aria-label", "aria-labelledby", or "title".
(cardElement.querySelector('button, input, select, textarea') && cardElement.getAttribute('aria-label')) ||
(cardElement.querySelector('button, input, select, textarea') &&
hasOriginalAriaLabel &&
cardElement.getAttribute('aria-label')) ||
cardElement.getAttribute('aria-labelledby') ||
cardElement.getAttribute('title')
? 'form'
: 'figure'
),
);

return () => {
undoRole();
undoAriaLabel?.();
};
},
[]
);

Expand Down
Loading