From d794c41d1fadd1860d63e9b0e7aa6abaf66fd16d Mon Sep 17 00:00:00 2001 From: uzirthapa Date: Thu, 26 Mar 2026 21:08:56 +0000 Subject: [PATCH 1/9] fix: Screen reader not announcing Adaptive Card content in stacked layout Screen readers (Narrator/NVDA) were not announcing Adaptive Card content when focused because stacked layout attachment rows lacked tabIndex and cards without the `speak` property had no aria-label for screen readers to announce. - Add `tabIndex={0}` to stacked layout AttachmentRow for keyboard focus - Add focus-visible styling using existing CSS custom properties - Derive `aria-label` from card text content when `speak` is not set Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 +++ .../AdaptiveCardHacks/useRoleModEffect.ts | 26 ++++++++++++++++--- .../component/src/Activity/AttachmentRow.tsx | 2 +- .../src/Activity/StackedLayout.module.css | 10 +++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e80af1d2d..534a0fd0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts index 2c84be189d..380f8806c8 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts @@ -34,8 +34,22 @@ 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 = () => {}; + + if (!cardElement.getAttribute('aria-label')) { + const textContent = (cardElement.textContent || '').replace(/\s+/g, ' ').trim(); + + if (textContent) { + const label = textContent.length > 200 ? textContent.slice(0, 200) + '\u2026' : textContent; + + undoAriaLabel = setOrRemoveAttributeIfFalseWithUndo(cardElement, 'aria-label', label); + } + } + + const undoRole = setOrRemoveAttributeIfFalseWithUndo( cardElement, 'role', // "form" role requires either "aria-label", "aria-labelledby", or "title". @@ -44,7 +58,13 @@ export default function useRoleModEffect( cardElement.getAttribute('title') ? 'form' : 'figure' - ), + ); + + return () => { + undoRole(); + undoAriaLabel(); + }; + }, [] ); diff --git a/packages/component/src/Activity/AttachmentRow.tsx b/packages/component/src/Activity/AttachmentRow.tsx index 5200185893..2e1c0ba8b9 100644 --- a/packages/component/src/Activity/AttachmentRow.tsx +++ b/packages/component/src/Activity/AttachmentRow.tsx @@ -35,7 +35,7 @@ function AttachmentRow(props: AttachmentRowProps) { const classNames = useStyles(styles); return ( -
+
{showBubble ? ( Date: Fri, 27 Mar 2026 15:40:23 +0000 Subject: [PATCH 2/9] fix: Use inset outline-offset so focus ring is visible inside overflow:hidden parent The stacked layout content area has overflow:hidden, which clips outlines rendered outside the element box. Using a negative outline-offset renders the focus ring inside the element, making it visible. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/component/src/Activity/StackedLayout.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/component/src/Activity/StackedLayout.module.css b/packages/component/src/Activity/StackedLayout.module.css index 1e981c8e7f..cb3cfc0cb2 100644 --- a/packages/component/src/Activity/StackedLayout.module.css +++ b/packages/component/src/Activity/StackedLayout.module.css @@ -61,6 +61,7 @@ outline: var(--webchat__border-width--transcript-visual-keyboard-indicator) var(--webchat__border-style--transcript-visual-keyboard-indicator) var(--webchat__color--transcript-visual-keyboard-indicator); + outline-offset: calc(-1 * var(--webchat__border-width--transcript-visual-keyboard-indicator)); } } From 7c4c2f8a4baa3a95ed3877f783fab45c0806867f Mon Sep 17 00:00:00 2001 From: uzirthapa Date: Fri, 27 Mar 2026 15:45:14 +0000 Subject: [PATCH 3/9] Revert "fix: Use inset outline-offset so focus ring is visible inside overflow:hidden parent" This reverts commit e91b48dea47012e1a60e088323ad0a8260d6f93d. --- packages/component/src/Activity/StackedLayout.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/component/src/Activity/StackedLayout.module.css b/packages/component/src/Activity/StackedLayout.module.css index cb3cfc0cb2..1e981c8e7f 100644 --- a/packages/component/src/Activity/StackedLayout.module.css +++ b/packages/component/src/Activity/StackedLayout.module.css @@ -61,7 +61,6 @@ outline: var(--webchat__border-width--transcript-visual-keyboard-indicator) var(--webchat__border-style--transcript-visual-keyboard-indicator) var(--webchat__color--transcript-visual-keyboard-indicator); - outline-offset: calc(-1 * var(--webchat__border-width--transcript-visual-keyboard-indicator)); } } From bae084c5015c14cb3500fcc324974d81c1928a3f Mon Sep 17 00:00:00 2001 From: uzirthapa Date: Fri, 27 Mar 2026 16:01:44 +0000 Subject: [PATCH 4/9] test: Add e2e tests for Adaptive Card screen reader a11y fix - hack.roleMod.ariaLabelFromTextContent.html: Tests that aria-label is derived from text content when speak is not set, and that speak value is used when present - attachmentRow.focusable.html: Tests that stacked layout attachment rows have tabIndex="0" and are focusable Co-Authored-By: Claude Opus 4.6 (1M context) --- .../adaptiveCard/attachmentRow.focusable.html | 87 +++++++++++++++++ ...hack.roleMod.ariaLabelFromTextContent.html | 95 +++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 __tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html create mode 100644 __tests__/html2/accessibility/adaptiveCard/hack.roleMod.ariaLabelFromTextContent.html diff --git a/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html b/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html new file mode 100644 index 0000000000..0356a513eb --- /dev/null +++ b/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html @@ -0,0 +1,87 @@ + + + + + + + + + +
+ + + 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..88e3abb681 --- /dev/null +++ b/__tests__/html2/accessibility/adaptiveCard/hack.roleMod.ariaLabelFromTextContent.html @@ -0,0 +1,95 @@ + + + + + + + + + +
+ + + From 997e9eee4deb3e270ab4e552fc5446989ef36f93 Mon Sep 17 00:00:00 2001 From: uzirthapa Date: Fri, 27 Mar 2026 18:09:13 +0000 Subject: [PATCH 5/9] fix: Address lint errors in a11y changes - Prettier: break AttachmentRow props onto separate lines - no-empty-function: use named noOp instead of inline empty arrow - require-unicode-regexp: add 'u' flag to regex - no-magic-numbers: extract ARIA_LABEL_MAX_LENGTH constant Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AdaptiveCardHacks/useRoleModEffect.ts | 14 +++++++++++--- packages/component/src/Activity/AttachmentRow.tsx | 7 ++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts index 380f8806c8..3a1f30f421 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts @@ -5,6 +5,11 @@ import useAdaptiveCardModEffect from './private/useAdaptiveCardModEffect'; import type { AdaptiveCard } from 'adaptivecards'; +const ARIA_LABEL_MAX_LENGTH = 200; + +// eslint-disable-next-line no-empty-function -- initialized as no-op, reassigned when aria-label is set +const noOp = () => {}; + /** * Accessibility: "role" attribute must be set if "aria-label" is set. * @@ -37,13 +42,16 @@ export default function useRoleModEffect( () => (_, 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 = () => {}; + let undoAriaLabel: () => void = noOp; if (!cardElement.getAttribute('aria-label')) { - const textContent = (cardElement.textContent || '').replace(/\s+/g, ' ').trim(); + const textContent = (cardElement.textContent || '').replace(/\s+/gu, ' ').trim(); if (textContent) { - const label = textContent.length > 200 ? textContent.slice(0, 200) + '\u2026' : textContent; + const label = + textContent.length > ARIA_LABEL_MAX_LENGTH + ? textContent.slice(0, ARIA_LABEL_MAX_LENGTH) + '\u2026' + : textContent; undoAriaLabel = setOrRemoveAttributeIfFalseWithUndo(cardElement, 'aria-label', label); } diff --git a/packages/component/src/Activity/AttachmentRow.tsx b/packages/component/src/Activity/AttachmentRow.tsx index 2e1c0ba8b9..7aa1e84513 100644 --- a/packages/component/src/Activity/AttachmentRow.tsx +++ b/packages/component/src/Activity/AttachmentRow.tsx @@ -35,7 +35,12 @@ function AttachmentRow(props: AttachmentRowProps) { const classNames = useStyles(styles); return ( -
+
{showBubble ? ( Date: Fri, 27 Mar 2026 20:18:38 +0000 Subject: [PATCH 6/9] fix: Address lint errors in a11y changes - Remove empty arrow noOp function that violated @typescript-eslint/no-empty-function - Use optional chaining for undoAriaLabel call instead Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Attachment/AdaptiveCardHacks/useRoleModEffect.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts index 3a1f30f421..aba552b565 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardHacks/useRoleModEffect.ts @@ -7,9 +7,6 @@ import type { AdaptiveCard } from 'adaptivecards'; const ARIA_LABEL_MAX_LENGTH = 200; -// eslint-disable-next-line no-empty-function -- initialized as no-op, reassigned when aria-label is set -const noOp = () => {}; - /** * Accessibility: "role" attribute must be set if "aria-label" is set. * @@ -42,7 +39,7 @@ export default function useRoleModEffect( () => (_, 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 = noOp; + let undoAriaLabel: (() => void) | undefined; if (!cardElement.getAttribute('aria-label')) { const textContent = (cardElement.textContent || '').replace(/\s+/gu, ' ').trim(); @@ -70,7 +67,7 @@ export default function useRoleModEffect( return () => { undoRole(); - undoAriaLabel(); + undoAriaLabel?.(); }; }, [] From 78989127954d6f2ec5620baaa15d5c9e3d40bf3d Mon Sep 17 00:00:00 2001 From: uzirthapa Date: Mon, 30 Mar 2026 16:42:04 +0000 Subject: [PATCH 7/9] ci: Retrigger CI to verify flaky useTextBox test The useTextBox.html test fails with landmark-unique axe violation, which appears to be a pre-existing flaky test unrelated to our changes. Co-Authored-By: Claude Opus 4.6 (1M context) From a7899ae3ce5456ae5ea7c78d0667824a16802688 Mon Sep 17 00:00:00 2001 From: uzirthapa Date: Mon, 30 Mar 2026 16:43:44 +0000 Subject: [PATCH 8/9] fix: Use tabIndex={-1} to avoid breaking focus traps in Adaptive Cards tabIndex={0} added an extra tab stop inside the card's focus trap, causing SHIFT-TAB from card controls to land on the attachment row instead of the expected element. Using tabIndex={-1} makes the row programmatically focusable (for screen readers via virtual cursor) without adding it to the Tab order. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../accessibility/adaptiveCard/attachmentRow.focusable.html | 6 +++--- packages/component/src/Activity/AttachmentRow.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html b/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html index 0356a513eb..91a017087d 100644 --- a/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html +++ b/__tests__/html2/accessibility/adaptiveCard/attachmentRow.focusable.html @@ -58,10 +58,10 @@ const attachmentRows = document.querySelectorAll('[aria-roledescription="attachment"]'); - // Both attachment rows should be focusable via tabIndex. + // Both attachment rows should be programmatically focusable via tabIndex="-1". expect(attachmentRows).toHaveProperty('length', 2); - expect(attachmentRows[0].getAttribute('tabindex')).toBe('0'); - expect(attachmentRows[1].getAttribute('tabindex')).toBe('0'); + 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'); diff --git a/packages/component/src/Activity/AttachmentRow.tsx b/packages/component/src/Activity/AttachmentRow.tsx index 7aa1e84513..f4bd85b43a 100644 --- a/packages/component/src/Activity/AttachmentRow.tsx +++ b/packages/component/src/Activity/AttachmentRow.tsx @@ -39,7 +39,7 @@ function AttachmentRow(props: AttachmentRowProps) { aria-roledescription="attachment" className={classNames['stacked-layout__attachment-row']} role="group" - tabIndex={0} + tabIndex={-1} > {showBubble ? ( From 86d283fd0f473ff660dd7722fa096155d5e5c200 Mon Sep 17 00:00:00 2001 From: uzirthapa Date: Mon, 30 Mar 2026 18:29:23 +0000 Subject: [PATCH 9/9] ci: Retrigger CI for flaky useTextBox test Co-Authored-By: Claude Opus 4.6 (1M context)