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 @@ -359,6 +359,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 @@ -5,6 +5,8 @@ import useAdaptiveCardModEffect from './private/useAdaptiveCardModEffect';

import type { AdaptiveCard } from 'adaptivecards';

const ARIA_LABEL_MAX_LENGTH = 200;

/**
* Accessibility: "role" attribute must be set if "aria-label" is set.
*
Expand Down Expand Up @@ -34,17 +36,48 @@ 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 visible text content so screen readers can announce it.
let undoAriaLabel: (() => void) | undefined;

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

if (textContent) {
const label =
textContent.length > ARIA_LABEL_MAX_LENGTH
? textContent.slice(0, ARIA_LABEL_MAX_LENGTH) + '\u2026'
: textContent;

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

// 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