Skip to content
Open
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,10 @@ Breaking changes in this release:

### Fixed

- Fixed screen reader (Narrator/NVDA) not announcing Adaptive Card content when focused in stacked layout, by [@uzirthapa](https://github.com/uzirthapa)
- Made stacked layout attachment rows keyboard-focusable with `tabIndex={0}`
- Added focus-visible styling for stacked attachment rows
- Adaptive Cards without `speak` property now derive `aria-label` from visible text 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,87 @@
<!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 be programmatically focusable via tabIndex="-1".
expect(attachmentRows).toHaveProperty('length', 2);
expect(attachmentRows[0].getAttribute('tabindex')).toBe('-1');
expect(attachmentRows[1].getAttribute('tabindex')).toBe('-1');

// Both should have role="group".
expect(attachmentRows[0].getAttribute('role')).toBe('group');
expect(attachmentRows[1].getAttribute('role')).toBe('group');

// Focus on the first attachment row and verify it receives focus.
attachmentRows[0].focus();

await pageConditions.became(
'focus is on the first attachment row',
() => document.activeElement === attachmentRows[0],
1000
);

// The Adaptive Card inside should have an aria-label derived from text content.
const card = attachmentRows[0].querySelector('.ac-adaptiveCard');

expect(card.getAttribute('aria-label')).toContain('First card content');
expect(card.getAttribute('role')).toBeTruthy();
});
</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,
// and role should be "form" because it has inputs and an aria-label.
expect(cardFormNoSpeak.getAttribute('aria-label')).toBeTruthy();
expect(cardFormNoSpeak.getAttribute('role')).toBe('form');
});
</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,8 +36,25 @@ export default function useRoleModEffect(
adaptiveCard: AdaptiveCard
): readonly [(cardElement: HTMLElement) => void, () => void] {
const modder = useMemo(
() => (_, cardElement: HTMLElement) =>
setOrRemoveAttributeIfFalseWithUndo(
() => (_, cardElement: HTMLElement) => {
// 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 (!cardElement.getAttribute('aria-label')) {
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);
}
}

const undoRole = setOrRemoveAttributeIfFalseWithUndo(
cardElement,
'role',
// "form" role requires either "aria-label", "aria-labelledby", or "title".
Expand All @@ -44,7 +63,13 @@ export default function useRoleModEffect(
cardElement.getAttribute('title')
? 'form'
: 'figure'
),
);

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

Expand Down
7 changes: 6 additions & 1 deletion packages/component/src/Activity/AttachmentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ function AttachmentRow(props: AttachmentRowProps) {
const classNames = useStyles(styles);

return (
<div aria-roledescription="attachment" className={classNames['stacked-layout__attachment-row']} role="group">
<div
aria-roledescription="attachment"
className={classNames['stacked-layout__attachment-row']}
role="group"
tabIndex={-1}
>
<ScreenReaderText text={attachedAlt} />
{showBubble ? (
<Bubble
Expand Down
10 changes: 10 additions & 0 deletions packages/component/src/Activity/StackedLayout.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@
.stacked-layout__attachment-row {
margin-block-start: var(--webchat__padding--regular);
width: 100%;

&:focus {
outline: 0;
}

&:focus-visible {
outline: var(--webchat__border-width--transcript-visual-keyboard-indicator)
var(--webchat__border-style--transcript-visual-keyboard-indicator)
var(--webchat__color--transcript-visual-keyboard-indicator);
}
Comment on lines +56 to +64
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The &:focus { outline: 0; } rule removes the default focus indicator, but the custom outline is only applied in :focus-visible. In browsers that don’t support :focus-visible (or when it doesn’t match), this leaves the attachment row with no visible focus indicator. Consider either removing the :focus outline reset, or providing a non-:focus-visible fallback (e.g., apply the indicator on :focus and optionally suppress it with :focus:not(:focus-visible)).

Copilot uses AI. Check for mistakes.
}

&.stacked-layout--no-message .stacked-layout__attachment-list .stacked-layout__attachment-row:first-child {
Expand Down
Loading