Skip to content

Commit e8073b3

Browse files
OEvgenyEugene OlonovCopilot
authored
Render card images with empty alternate text as decorative (#5877)
* Render card images with empty alternate text as decorative Adaptive Cards only sets the "alt" attribute when the alternate text is not empty. An image sent with `altText: ""` (Adaptive Card `Image`) or `alt: ""` (hero, thumbnail, receipt card) rendered without "alt", the same as an image without alternate text, so bots could not mark images as decorative. Add an Adaptive Card mod that sets `alt=""` on images whose `altText` is an empty string. Images with a select action have a role (button or link) and are skipped. Images without alternate text are unchanged. Related to #5747. * Add PR number to changelog * Explain why image with select action keeps no alt --------- Co-authored-by: Eugene Olonov <eugeneolonov@microsoft.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: OEvgeny <2841858+OEvgeny@users.noreply.github.com>
1 parent a191a10 commit e8073b3

4 files changed

Lines changed: 174 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Legends:
3434

3535
- Fixed the "Copied" confirmation of the Copy button disappearing too quickly: it is now shown for 5 seconds in all themes, fixes [#5857](https://github.com/microsoft/BotFramework-WebChat/issues/5857), in PR [#5872](https://github.com/microsoft/BotFramework-WebChat/pull/5872), by [@OEvgeny](https://github.com/OEvgeny)
3636
- Fixed screen readers repeating the full bot response when focus enters message actions such as Copy, Like, or Dislike, resolving [#5849](https://github.com/microsoft/BotFramework-WebChat/issues/5849), by [@asalsa](https://github.com/asalsa)
37+
- Fixed card images with empty alternate text (`altText: ""` in Adaptive Cards or `alt: ""` in rich cards) not being rendered as decorative images, related to [#5747](https://github.com/microsoft/BotFramework-WebChat/issues/5747), in PR [#5877](https://github.com/microsoft/BotFramework-WebChat/pull/5877), by [@OEvgeny](https://github.com/OEvgeny)
3738
- Fixed the keyboard focus indicator of the "Previous" and "Next" flipper buttons in carousel layout: they now show a visible focus ring, and the focused flipper is shown even at the start or end of the carousel, fixes [#5704](https://github.com/microsoft/BotFramework-WebChat/issues/5704) and [#5744](https://github.com/microsoft/BotFramework-WebChat/issues/5744), in PR [#5876](https://github.com/microsoft/BotFramework-WebChat/pull/5876), by [@OEvgeny](https://github.com/OEvgeny)
3839

3940
## [4.19.2] - 2026-10-08
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<!doctype html>
2+
<html lang="en-US">
3+
<head>
4+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
5+
<script crossorigin="anonymous" src="/test-harness.js"></script>
6+
<script crossorigin="anonymous" src="/test-page-object.js"></script>
7+
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
8+
</head>
9+
<body>
10+
<main id="webchat"></main>
11+
<script type="importmap">
12+
{
13+
"imports": {
14+
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for"
15+
}
16+
}
17+
</script>
18+
<script type="module">
19+
import { waitFor } from '@testduet/wait-for';
20+
21+
async function getAccessibilityNodes(imageElements) {
22+
return (
23+
await Promise.all(imageElements.map(imageElement => testHelpers.getAccessibilityNode(imageElement)))
24+
).map(({ ignored, name, role }) => (ignored ? { ignored } : { ignored, name: name?.value, role: role?.value }));
25+
}
26+
27+
run(async function () {
28+
const { directLine, store } = testHelpers.createDirectLineEmulator();
29+
30+
WebChat.renderWebChat({ directLine, store }, document.getElementById('webchat'));
31+
32+
await pageConditions.uiConnected();
33+
34+
// WHEN: Bot sends cards with images with empty alternate text and with alternate text.
35+
await directLine.emulateIncomingActivity({
36+
attachments: [
37+
{
38+
content: { images: [{ alt: '', url: '/assets/bot-avatar.jpg' }], title: 'Hero card with empty alt' },
39+
contentType: 'application/vnd.microsoft.card.hero'
40+
},
41+
{
42+
content: {
43+
images: [{ alt: 'Bot avatar', url: '/assets/bot-avatar.jpg' }],
44+
title: 'Hero card with alt'
45+
},
46+
contentType: 'application/vnd.microsoft.card.hero'
47+
},
48+
{
49+
content: {
50+
images: [{ alt: '', url: '/assets/bot-avatar.jpg' }],
51+
title: 'Thumbnail card with empty alt'
52+
},
53+
contentType: 'application/vnd.microsoft.card.thumbnail'
54+
},
55+
{
56+
content: {
57+
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
58+
body: [
59+
{ altText: '', type: 'Image', url: '/assets/bot-avatar.jpg' },
60+
{ altText: 'Bot avatar', type: 'Image', url: '/assets/bot-avatar.jpg' },
61+
{ images: [{ altText: '', type: 'Image', url: '/assets/bot-avatar.jpg' }], type: 'ImageSet' },
62+
{
63+
columns: [
64+
{ items: [{ altText: '', type: 'Image', url: '/assets/bot-avatar.jpg' }], type: 'Column' }
65+
],
66+
type: 'ColumnSet'
67+
},
68+
{
69+
altText: '',
70+
selectAction: { title: 'Open image', type: 'Action.Submit' },
71+
type: 'Image',
72+
url: '/assets/bot-avatar.jpg'
73+
}
74+
],
75+
type: 'AdaptiveCard',
76+
version: '1.5'
77+
},
78+
contentType: 'application/vnd.microsoft.card.adaptive'
79+
}
80+
],
81+
type: 'message'
82+
});
83+
84+
await pageConditions.numActivitiesShown(1);
85+
await waitFor(() => expect(pageElements.activities()[0].querySelectorAll('img.ac-image')).toHaveLength(8));
86+
await pageConditions.allImagesLoaded();
87+
88+
const imageElements = Array.from(pageElements.activities()[0].querySelectorAll('img.ac-image'));
89+
90+
// THEN: Images with empty alternate text should have `alt=""`, except image with select action.
91+
// Image with select action is a button named by the action title (`aria-label`), it is not decorative.
92+
// `alt=""` would not change its name, and axe would report `presentation-role-conflict` on it.
93+
expect(imageElements.map(imageElement => imageElement.getAttribute('alt'))).toEqual([
94+
'', // Hero card with empty alt.
95+
'Bot avatar', // Hero card with alt.
96+
'', // Thumbnail card with empty alt.
97+
'', // Adaptive Card: Image with empty alt.
98+
'Bot avatar', // Adaptive Card: Image with alt.
99+
'', // Adaptive Card: Image with empty alt inside ImageSet.
100+
'', // Adaptive Card: Image with empty alt inside ColumnSet.
101+
null // Adaptive Card: Image with empty alt and select action, `alt` is untouched.
102+
]);
103+
104+
// THEN: Images with empty alternate text should be decorative, image with select action should be a button.
105+
expect(await getAccessibilityNodes(imageElements)).toEqual([
106+
{ ignored: true }, // Hero card with empty alt.
107+
{ ignored: false, name: 'Bot avatar', role: 'image' }, // Hero card with alt.
108+
{ ignored: true }, // Thumbnail card with empty alt.
109+
{ ignored: true }, // Adaptive Card: Image with empty alt.
110+
{ ignored: false, name: 'Bot avatar', role: 'image' }, // Adaptive Card: Image with alt.
111+
{ ignored: true }, // Adaptive Card: Image with empty alt inside ImageSet.
112+
{ ignored: true }, // Adaptive Card: Image with empty alt inside ColumnSet.
113+
{ ignored: false, name: 'Open image', role: 'button' } // Adaptive Card: Image with empty alt and select action.
114+
]);
115+
});
116+
</script>
117+
</body>
118+
</html>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useMemo } from 'react';
2+
import { is, literal, object } from 'valibot';
3+
4+
import bunchUndos from '../../DOMManipulationWithUndo/bunchUndos';
5+
import setOrRemoveAttributeIfFalseWithUndo from '../../DOMManipulationWithUndo/setOrRemoveAttributeIfFalseWithUndo';
6+
import findDOMNodeOwner from './private/findDOMNodeOwner';
7+
import useAdaptiveCardModEffect from './private/useAdaptiveCardModEffect';
8+
9+
import type { AdaptiveCard } from 'adaptivecards';
10+
11+
const emptyAltTextSchema = object({ altText: literal('') });
12+
13+
// TODO: [P2] Remove this mod when Adaptive Cards renders `alt=""` for `Image` with empty `altText`.
14+
// Currently, `Image.internalRender()` only sets the "alt" attribute when `altText` is truthy.
15+
/**
16+
* Accessibility: Image with empty alternate text should be decorative.
17+
*
18+
* Bot marks an image as decorative by setting its alternate text to an empty string,
19+
* e.g. `altText: ""` in Adaptive Cards `Image`, or `alt: ""` in hero card image.
20+
*
21+
* Adaptive Cards only sets the "alt" attribute when the alternate text is not empty.
22+
* Without the "alt" attribute, the image is not decorative and screen readers may announce it.
23+
*
24+
* We need to set `alt=""` on these images.
25+
*
26+
* Image with select action is not decorative: Adaptive Cards renders it as a button or link,
27+
* named by the action title (`aria-label`). We must not set `alt=""` on it: it would not change its accessible name,
28+
* and axe reports `presentation-role-conflict` on focusable `<img alt="">` with ARIA attributes.
29+
*/
30+
export default function useImageAltTextModEffect(
31+
adaptiveCard: AdaptiveCard
32+
): readonly [(cardElement: HTMLElement) => void, () => void] {
33+
const modder = useMemo(
34+
() => (adaptiveCard: AdaptiveCard, cardElement: HTMLElement) =>
35+
bunchUndos(
36+
Array.from(cardElement.querySelectorAll<HTMLImageElement>('img:not([role])'))
37+
.filter(imageElement => {
38+
const cardObject = findDOMNodeOwner(adaptiveCard, imageElement);
39+
40+
// We cannot use "instanceof" check here, because web devs may bring their own version of Adaptive Cards package.
41+
return cardObject?.getJsonTypeName() === 'Image' && is(emptyAltTextSchema, cardObject);
42+
})
43+
.map(imageElement => setOrRemoveAttributeIfFalseWithUndo(imageElement, 'alt', ''))
44+
),
45+
[]
46+
);
47+
48+
return useAdaptiveCardModEffect(modder, adaptiveCard);
49+
}

‎packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import useValueRef from './AdaptiveCardHacks/private/useValueRef';
2626
import useActionShouldBePushButtonModEffect from './AdaptiveCardHacks/useActionShouldBePushButtonModEffect';
2727
import useActiveElementModEffect from './AdaptiveCardHacks/useActiveElementModEffect';
2828
import useDisabledModEffect from './AdaptiveCardHacks/useDisabledModEffect';
29+
import useImageAltTextModEffect from './AdaptiveCardHacks/useImageAltTextModEffect';
2930
import usePersistValuesModEffect from './AdaptiveCardHacks/usePersistValuesModEffect';
3031
import useRoleModEffect from './AdaptiveCardHacks/useRoleModEffect';
3132
import { directLineHeadlessCardActionSchema } from './private/directLineCardActionSchema';
@@ -207,13 +208,15 @@ function AdaptiveCardRenderer(props: AdaptiveCardRendererProps) {
207208
useActionShouldBePushButtonModEffect(adaptiveCard);
208209
const [applyActiveElementMod, undoActiveElementMod] = useActiveElementModEffect(adaptiveCard);
209210
const [applyDisabledMod, undoDisabledMod] = useDisabledModEffect(adaptiveCard);
211+
const [applyImageAltTextMod, undoImageAltTextMod] = useImageAltTextModEffect(adaptiveCard);
210212
const [applyPersistValuesMod, undoPersistValuesMod] = usePersistValuesModEffect(adaptiveCard);
211213
const [applyRoleMod, undoRoleMod] = useRoleModEffect(adaptiveCard);
212214

213215
const { element, errors }: { element?: HTMLElement; errors?: Error[] } = useMemo(() => {
214216
undoActionShouldBePushButtonMod();
215217
undoActiveElementMod();
216218
undoDisabledMod();
219+
undoImageAltTextMod();
217220
undoPersistValuesMod();
218221
undoRoleMod();
219222

@@ -233,6 +236,7 @@ function AdaptiveCardRenderer(props: AdaptiveCardRendererProps) {
233236
undoActionShouldBePushButtonMod,
234237
undoActiveElementMod,
235238
undoDisabledMod,
239+
undoImageAltTextMod,
236240
undoPersistValuesMod,
237241
undoRoleMod
238242
]);
@@ -257,6 +261,7 @@ function AdaptiveCardRenderer(props: AdaptiveCardRendererProps) {
257261
applyActionShouldBePushButtonMod(element, actionPerformedClassName);
258262
applyActiveElementMod(element);
259263
applyDisabledMod(element, disabled);
264+
applyImageAltTextMod(element);
260265
applyPersistValuesMod(element);
261266
applyRoleMod(element);
262267
}
@@ -265,6 +270,7 @@ function AdaptiveCardRenderer(props: AdaptiveCardRendererProps) {
265270
applyActionShouldBePushButtonMod,
266271
applyActiveElementMod,
267272
applyDisabledMod,
273+
applyImageAltTextMod,
268274
applyPersistValuesMod,
269275
applyRoleMod,
270276
disabled,

0 commit comments

Comments
 (0)