diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e80af1d2d..b3c28d3b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html b/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html new file mode 100644 index 0000000000..8580f3f187 --- /dev/null +++ b/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html @@ -0,0 +1,78 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html2/accessibility/adaptiveCard/hack.roleMod.ariaLabelFromTextContent.html b/__tests__/html2/accessibility/adaptiveCard/hack.roleMod.ariaLabelFromTextContent.html new file mode 100644 index 0000000000..866da6a4a0 --- /dev/null +++ b/__tests__/html2/accessibility/adaptiveCard/hack.roleMod.ariaLabelFromTextContent.html @@ -0,0 +1,95 @@ + + + + + + + + + +
+ + + diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts index 2c84be189d..b6d9ee0be6 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts @@ -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. * @@ -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
. + 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?.(); + }; + }, [] );