Skip to content

Commit 350e841

Browse files
git-nandorclaude
andcommitted
fix(ui-modal): add whitespace between header text nodes in scroll body aria-label
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) <noreply@anthropic.com>
1 parent d559a3f commit 350e841

2 files changed

Lines changed: 42 additions & 4 deletions

File tree

packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import canvas from '@instructure/ui-themes'
3131
import { View } from '@instructure/ui-view/latest'
3232
import type { ViewOwnProps } from '@instructure/ui-view/latest'
3333

34-
import { ModalBody } from '@instructure/ui-modal/latest'
34+
import { Modal, ModalBody, ModalHeader } from '@instructure/ui-modal/latest'
3535

3636
const BODY_TEXT = 'Modal-body-text'
3737

@@ -209,5 +209,36 @@ describe('<ModalBody />', () => {
209209

210210
await waitFor(() => expect(body).not.toHaveAttribute('tabindex'))
211211
})
212+
213+
it('labels the scrollable body from the header with a space between adjacent text nodes', async () => {
214+
mockScrollable(true)
215+
// The header reproduces what a `Heading` with `aiVariant="stacked"`
216+
// renders: a decorative "IgniteAI" text node immediately followed by the
217+
// title, with no whitespace between them. The body's aria-label must not
218+
// collapse these into "IgniteAI Nutrition Facts".
219+
const { findByText } = render(
220+
<Modal
221+
open
222+
label="AI information"
223+
size="small"
224+
shouldReturnFocus={false}
225+
onDismiss={() => {}}
226+
>
227+
<ModalHeader>
228+
<span aria-hidden="true">IgniteAI</span>
229+
{'AI Nutrition Facts'}
230+
</ModalHeader>
231+
<ModalBody>{BODY_TEXT}</ModalBody>
232+
</Modal>
233+
)
234+
const body = await findByText(BODY_TEXT)
235+
236+
await waitFor(() =>
237+
expect(body).toHaveAttribute(
238+
'aria-label',
239+
'IgniteAI AI Nutrition Facts'
240+
)
241+
)
242+
})
212243
})
213244
})

packages/ui-modal/src/Modal/v2/ModalHeader/index.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,19 @@ class ModalHeader extends Component<ModalHeaderProps> {
7575
: NodeFilter.FILTER_ACCEPT
7676
}
7777
})
78-
let text = ''
78+
const textParts: string[] = []
7979
let current
8080
while ((current = walker.nextNode())) {
81-
text += current.nodeValue
81+
// Trim each text node and drop empty ones so adjacent text nodes from
82+
// different elements (e.g. the decorative "IgniteAI" span a `Heading`
83+
// with `aiVariant` renders before its title) are separated by a single
84+
// space instead of being concatenated into "IgniteAI Nutrition Facts".
85+
const value = current.nodeValue?.trim()
86+
if (value) {
87+
textParts.push(value)
88+
}
8289
}
83-
return text
90+
return textParts.join(' ')
8491
}
8592

8693
handleRef = (el: HTMLDivElement | null) => {

0 commit comments

Comments
 (0)