From 0236c2a82216777647ca3bac6d32d2ee7c3a3082 Mon Sep 17 00:00:00 2001 From: Nandor_Czegledi Date: Wed, 15 Jul 2026 11:37:45 +0200 Subject: [PATCH] fix(ui-modal): add whitespace between header text nodes in scroll body aria-label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Modal body's scroll aria-label is assembled from the header's text nodes, which were concatenated with no separator. When the header contains a Heading with `aiVariant` (which renders a decorative "IgniteAI" text node before the title), this produced malformed labels like "IgniteAIAI Nutrition Facts" that VoiceOver announced incorrectly (WCAG 1.3.1). Trim each text node and join non-empty ones with a single space so adjacent text nodes from different elements are separated. Applies to the v2 ModalHeader. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/Modal/__tests__/ModalBody.test.tsx | 33 ++++++++++++++++++- .../src/Modal/v2/ModalHeader/index.tsx | 13 ++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx b/packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx index e3d043efee..dda44e9613 100644 --- a/packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx +++ b/packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx @@ -31,7 +31,7 @@ import canvas from '@instructure/ui-themes' import { View } from '@instructure/ui-view/latest' import type { ViewOwnProps } from '@instructure/ui-view/latest' -import { ModalBody } from '@instructure/ui-modal/latest' +import { Modal, ModalBody, ModalHeader } from '@instructure/ui-modal/latest' const BODY_TEXT = 'Modal-body-text' @@ -209,5 +209,36 @@ describe('', () => { await waitFor(() => expect(body).not.toHaveAttribute('tabindex')) }) + + it('labels the scrollable body from the header with a space between adjacent text nodes', async () => { + mockScrollable(true) + // The header reproduces what a `Heading` with `aiVariant="stacked"` + // renders: a decorative "IgniteAI" text node immediately followed by the + // title, with no whitespace between them. The body's aria-label must not + // collapse these into "IgniteAI Nutrition Facts". + const { findByText } = render( + {}} + > + + + {'AI Nutrition Facts'} + + {BODY_TEXT} + + ) + const body = await findByText(BODY_TEXT) + + await waitFor(() => + expect(body).toHaveAttribute( + 'aria-label', + 'IgniteAI AI Nutrition Facts' + ) + ) + }) }) }) diff --git a/packages/ui-modal/src/Modal/v2/ModalHeader/index.tsx b/packages/ui-modal/src/Modal/v2/ModalHeader/index.tsx index 13403c8f6e..42820d997d 100644 --- a/packages/ui-modal/src/Modal/v2/ModalHeader/index.tsx +++ b/packages/ui-modal/src/Modal/v2/ModalHeader/index.tsx @@ -75,12 +75,19 @@ class ModalHeader extends Component { : NodeFilter.FILTER_ACCEPT } }) - let text = '' + const textParts: string[] = [] let current while ((current = walker.nextNode())) { - text += current.nodeValue + // Trim each text node and drop empty ones so adjacent text nodes from + // different elements (e.g. the decorative "IgniteAI" span a `Heading` + // with `aiVariant` renders before its title) are separated by a single + // space instead of being concatenated into "IgniteAI Nutrition Facts". + const value = current.nodeValue?.trim() + if (value) { + textParts.push(value) + } } - return text + return textParts.join(' ') } handleRef = (el: HTMLDivElement | null) => {