Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .changelog/5913-fix-liveregion-card-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Fixed screen readers announcing new Adaptive Card inputs without their labels and values, in PR [#5913](https://github.com/microsoft/BotFramework-WebChat/pull/5913), by [@OEvgeny](https://github.com/OEvgeny)
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<!-- Shows the live region, so the snapshot shows the copy sent to screen readers. -->
<link href="/assets/accessibility.liveRegionAttachment.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"botframework-webchat/": "/__dist__/packages/bundle/static/botframework-webchat/",
"react": "/__dist__/packages/bundle/static/react.js",
"react-dom": "/__dist__/packages/bundle/static/react-dom.js",
"react-dom/client": "/__dist__/packages/bundle/static/react-dom/client.js"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { createStoreWithOptions, ReactWebChat } from 'botframework-webchat';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';

// TODO: This is for `createDirectLineEmulator` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions };

run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();

// GIVEN: Web Chat keeps the live region copy of new messages, so it can be checked and seen in the snapshot.
createRoot(document.getElementById('webchat')).render(
createElement(ReactWebChat, {
directLine,
store,
styleOptions: { hideSendBox: true, internalLiveRegionFadeAfter: 60000 }
})
);

await pageConditions.uiConnected();

// WHEN: The bot sends an Adaptive Card form, with inputs labelled by the "label" property (Adaptive Cards 1.3+).
await directLine.emulateIncomingActivity({
attachments: [
{
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
actions: [{ title: 'Place order', type: 'Action.Submit' }],
body: [
{ id: 'name', label: 'Your name', type: 'Input.Text' },
{ id: 'quantity', label: 'Quantity', type: 'Input.Number' },
{ id: 'date', label: 'Delivery date', type: 'Input.Date' },
{ id: 'time', label: 'Delivery time', type: 'Input.Time' },
{ id: 'note', placeholder: 'Add a note', type: 'Input.Text' } // No label: the card names it by its placeholder.
],
type: 'AdaptiveCard',
version: '1.5'
},
contentType: 'application/vnd.microsoft.card.adaptive'
}
],
from: { role: 'bot' },
type: 'message'
});

await pageConditions.numActivitiesShown(1);
await pageConditions.liveRegionStabilized();

const activity = pageElements.liveRegion().querySelector('.webchat__live-region-activity');
const inputs = Array.from(activity.querySelectorAll('input'));

// THEN: The live region copy names every input as the card in the transcript does: by its label, otherwise by its placeholder.
expect(await Promise.all(inputs.map(input => testHelpers.getAccessibleName(input)))).toEqual([
'Your name',
'Quantity',
'Delivery date',
'Delivery time',
'Add a note'
]);

// THEN: The labels are not repeated as text next to the inputs, so screen readers read each label once, as the input name.
expect(testHelpers.getAllTextContents(activity).map(text => text.trim())).toEqual([
'Bot said:',
'A card:',
'Place order' // The button.
]);

// THEN: The inputs in the copy are out of the tab order.
expect(inputs.map(({ tabIndex }) => tabIndex)).toEqual([-1, -1, -1, -1, -1]);

// THEN: The live region shows the inputs without label text, only the placeholder of the last one, and the accessibility check (no unlabeled inputs) passes.
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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" />
<!-- Shows the live region, so the snapshot shows the copy sent to screen readers. -->
<link href="/assets/accessibility.liveRegionAttachment.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"botframework-webchat/": "/__dist__/packages/bundle/static/botframework-webchat/",
"react": "/__dist__/packages/bundle/static/react.js",
"react-dom": "/__dist__/packages/bundle/static/react-dom.js",
"react-dom/client": "/__dist__/packages/bundle/static/react-dom/client.js"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { createStoreWithOptions, ReactWebChat } from 'botframework-webchat';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';

// TODO: This is for `createDirectLineEmulator` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions };

run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();

// GIVEN: Web Chat keeps the live region copy of new messages, so it can be checked and seen in the snapshot.
createRoot(document.getElementById('webchat')).render(
createElement(ReactWebChat, {
directLine,
store,
styleOptions: { hideSendBox: true, internalLiveRegionFadeAfter: 60000 }
})
);

await pageConditions.uiConnected();

// WHEN: The bot sends an Adaptive Card form with pre-filled inputs.
await directLine.emulateIncomingActivity({
attachments: [
{
content: {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
body: [
{ id: 'name', label: 'Your name', type: 'Input.Text', value: 'Ada' },
{ id: 'quantity', label: 'Quantity', type: 'Input.Number', value: 3 },
{ id: 'date', label: 'Delivery date', type: 'Input.Date', value: '2026-10-20' },
{ id: 'time', label: 'Delivery time', type: 'Input.Time', value: '14:30' },
{ id: 'gift', title: 'Gift wrap', type: 'Input.Toggle', value: 'true' } // Equals the default "valueOn": checked.
],
type: 'AdaptiveCard',
version: '1.5'
},
contentType: 'application/vnd.microsoft.card.adaptive'
}
],
from: { role: 'bot' },
type: 'message'
});

await pageConditions.numActivitiesShown(1);
await pageConditions.liveRegionStabilized();

const nodes = await Promise.all(
Array.from(pageElements.liveRegion().querySelectorAll('.webchat__live-region-activity input')).map(input =>
testHelpers.getAccessibilityNode(input)
)
);

// THEN: The live region copy exposes the pre-filled values as the card in the transcript does.
expect(nodes.map(({ name, value }) => [name?.value, value?.value])).toEqual([
['Your name', 'Ada'],
['Quantity', 3], // Spin buttons expose a number.
['Delivery date', '2026-10-20'],
['Delivery time', '14:30'],
['Gift wrap', undefined] // Checkboxes expose their state as "checked", not as a value.
]);

// THEN: The toggle in the copy is checked.
expect(nodes[4].properties.find(({ name }) => name === 'checked')?.value.value).toBe('true');

// THEN: The live region shows the inputs with their pre-filled values.
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -182,39 +182,57 @@ const AdaptiveCardAttachment = ({ content }) => {
return (
<div role="none">
<div role="none">{cardLabel}</div>
{/* Inputs are named and pre-filled as in the rendered card: named by their label, otherwise by their placeholder.
The label is not rendered as text, otherwise screen readers read it twice: as text and as the input name. */}
{inputs.map((input, index) =>
input instanceof ChoiceSetInput ? (
<AdaptiveCardChoiceSetInput input={input} key={index} />
) : input instanceof DateInput ? (
<label key={index}>
{input.title}
<input placeholder={input.placeholder} tabIndex={-1} type="date" />
</label>
<input
aria-label={input.label || undefined}
defaultValue={input.defaultValue}
key={index}
placeholder={input.placeholder}
tabIndex={-1}
type="date"
/>
) : input instanceof NumberInput ? (
<label key={index}>
{input.title}
<input placeholder={input.placeholder} tabIndex={-1} type="number" />
</label>
<input
aria-label={input.label || undefined}
defaultValue={input.defaultValue}
key={index}
placeholder={input.placeholder}
tabIndex={-1}
type="number"
/>
) : input instanceof OpenUrlAction || input instanceof ShowCardAction || input instanceof SubmitAction ? (
<div key={index} role="none">
<button tabIndex={-1} type="button">
{input.title}
</button>
</div>
) : input instanceof TextInput ? (
<label key={index}>
{input.title}
<input placeholder={input.placeholder} tabIndex={-1} type="text" />
</label>
<input
aria-label={input.label || undefined}
defaultValue={input.defaultValue}
key={index}
placeholder={input.placeholder}
tabIndex={-1}
type="text"
/>
) : input instanceof TimeInput ? (
<label key={index}>
{input.title}
<input placeholder={input.placeholder} tabIndex={-1} type="time" />
</label>
<input
aria-label={input.label || undefined}
defaultValue={input.defaultValue}
key={index}
placeholder={input.placeholder}
tabIndex={-1}
type="time"
/>
) : input instanceof ToggleInput ? (
<label key={index}>
{input.title}
<input defaultChecked={input.value === input.valueOn} tabIndex={-1} type="checkbox" />
<input defaultChecked={input.defaultValue === input.valueOn} tabIndex={-1} type="checkbox" />
</label>
) : (
false
Expand Down
Loading