Skip to content
Open
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
100 changes: 98 additions & 2 deletions packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
* SOFTWARE.
*/

import { render } from '@testing-library/react'
import { vi } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import { vi, beforeEach, afterEach } from 'vitest'
import '@testing-library/jest-dom'

import { color2hex } from '@instructure/ui-color-utils'
Expand Down Expand Up @@ -114,4 +114,100 @@ describe('<ModalBody />', () => {
}
})
})

describe('tab stop on a scrollable body', () => {
let scrollHeightSpy: ReturnType<typeof vi.spyOn>
let rectSpy: ReturnType<typeof vi.spyOn>
let originalResizeObserver: typeof globalThis.ResizeObserver

const mockScrollable = (scrollable: boolean) => {
scrollHeightSpy = vi
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
.mockReturnValue(scrollable ? 500 : 50)
rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue({
height: 50,
width: 100,
top: 0,
left: 0,
right: 100,
bottom: 50,
x: 0,
y: 0,
toJSON: () => {}
} as DOMRect)
}

beforeEach(() => {
originalResizeObserver = globalThis.ResizeObserver
globalThis.ResizeObserver = class {
cb: ResizeObserverCallback
constructor(cb: ResizeObserverCallback) {
this.cb = cb
}
observe() {
Promise.resolve().then(() =>
this.cb([], this as unknown as ResizeObserver)
)
}
unobserve() {}
disconnect() {}
} as unknown as typeof globalThis.ResizeObserver
})

afterEach(() => {
scrollHeightSpy?.mockRestore()
rectSpy?.mockRestore()
globalThis.ResizeObserver = originalResizeObserver
})

it('is a tab stop when scrollable and it has no focusable children', async () => {
mockScrollable(true)
const { findByText } = render(<ModalBody>{BODY_TEXT}</ModalBody>)
const body = await findByText(BODY_TEXT)

await waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
})

it('is not a tab stop when scrollable but it has a focusable child', async () => {
mockScrollable(true)
const { findByText } = render(
<ModalBody>
<button>focusable</button>
{BODY_TEXT}
</ModalBody>
)
const body = await findByText(BODY_TEXT)

await waitFor(() => expect(body).not.toHaveAttribute('tabindex'))
})

it('is not a tab stop when it is not scrollable', async () => {
mockScrollable(false)
const { findByText } = render(<ModalBody>{BODY_TEXT}</ModalBody>)
const body = await findByText(BODY_TEXT)

await waitFor(() => expect(body).toBeInTheDocument())
expect(body).not.toHaveAttribute('tabindex')
})

it('drops the tab stop when a focusable child is added later', async () => {
mockScrollable(true)
const { findByText, rerender } = render(
<ModalBody>{BODY_TEXT}</ModalBody>
)
const body = await findByText(BODY_TEXT)
await waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))

rerender(
<ModalBody>
<button>focusable</button>
{BODY_TEXT}
</ModalBody>
)

await waitFor(() => expect(body).not.toHaveAttribute('tabindex'))
})
})
})
38 changes: 35 additions & 3 deletions packages/ui-modal/src/Modal/v1/ModalBody/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { Component } from 'react'

import { View } from '@instructure/ui-view/v11_6'
import { omitProps } from '@instructure/ui-react-utils'
import { getCSSStyleDeclaration } from '@instructure/ui-dom-utils'
import { getCSSStyleDeclaration, findTabbable } from '@instructure/ui-dom-utils'

import { withStyle } from '@instructure/emotion'
import generateStyle from './styles'
Expand Down Expand Up @@ -55,6 +55,8 @@ class ModalBody extends Component<ModalBodyProps> {
}

ref: UIElement | null = null
private resizeObserver?: ResizeObserver
private mutationObserver?: MutationObserver

handleRef = (el: UIElement | null) => {
const { elementRef } = this.props
Expand Down Expand Up @@ -86,12 +88,39 @@ class ModalBody extends Component<ModalBodyProps> {
if (isFirefox) {
this.setState({ isFirefox })
}

@ToMESSKa ToMESSKa Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was needed to handle the the case when the modal body content changes dinamically (a scrollbar or a focusable element appears or disappears)

const finalRef = this.getFinalRef(this.ref)
if (finalRef && typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => this.forceUpdate())
this.resizeObserver.observe(finalRef)
this.mutationObserver = new MutationObserver(() => this.forceUpdate())
this.mutationObserver.observe(finalRef, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
'disabled',
'tabindex',
'hidden',
'href',
'contenteditable',
'type',
'class',
'style'
]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

based on the set of attributes that findTabbable reads

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you put this as a comment to findTabbble? This is important because we might replace findTabbable in the future, it has several flaws

})
}
}

componentDidUpdate() {
this.props.makeStyles?.()
}

componentWillUnmount() {
this.resizeObserver?.disconnect()
this.mutationObserver?.disconnect()
}

getFinalRef(el: UIElement): Element | undefined {
if (!el) {
return undefined
Expand Down Expand Up @@ -124,6 +153,8 @@ class ModalBody extends Component<ModalBodyProps> {
(finalRef.scrollHeight ?? 0) -
(finalRef.getBoundingClientRect()?.height ?? 0)
) > 1
const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0
const needsTabIndex = hasScrollbar && !hasTabbableChildren
return (
<ModalContext.Consumer>
{(value) => (
Expand All @@ -137,8 +168,9 @@ class ModalBody extends Component<ModalBodyProps> {
as={as}
css={this.props.styles?.modalBody}
padding={padding}
// check if there is a scrollbar, if so, the element has to be tabbable
{...(hasScrollbar
// check if there is a scrollbar and no focusable children, if so, the
// element has to be tabbable
{...(needsTabIndex
? {
tabIndex: 0,
'aria-label': value.bodyScrollAriaLabel
Expand Down
1 change: 1 addition & 0 deletions packages/ui-modal/src/Modal/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ type: embed
<Figure.Item>Modals should be able to be closed by clicking away, esc key and/or a close button</Figure.Item>
<Figure.Item>We recommend that modals begin with a heading (typically H2)</Figure.Item>
<Figure.Item>The Modal's header currently becomes non-sticky when the window height is too small, improving navigation of the Modal.Body, e.g., at higher zoom levels</Figure.Item>
<Figure.Item>`Modal.Body` automatically becomes a keyboard tab stop (its `tabIndex` is set to `0`) when it is vertically scrollable and contains no focusable elements, so it can be scrolled by keyboard-only users. This is re-evaluated dynamically as the body's content or size changes.</Figure.Item>
</Figure>
</Guidelines>
```
38 changes: 35 additions & 3 deletions packages/ui-modal/src/Modal/v2/ModalBody/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { Component } from 'react'

import { View } from '@instructure/ui-view/latest'
import { omitProps } from '@instructure/ui-react-utils'
import { getCSSStyleDeclaration } from '@instructure/ui-dom-utils'
import { getCSSStyleDeclaration, findTabbable } from '@instructure/ui-dom-utils'

import { withStyleNew } from '@instructure/emotion'
import generateStyle from './styles'
Expand Down Expand Up @@ -54,6 +54,8 @@ class ModalBody extends Component<ModalBodyProps> {
}

ref: UIElement | null = null
private resizeObserver?: ResizeObserver
private mutationObserver?: MutationObserver

handleRef = (el: UIElement | null) => {
const { elementRef } = this.props
Expand Down Expand Up @@ -85,12 +87,39 @@ class ModalBody extends Component<ModalBodyProps> {
if (isFirefox) {
this.setState({ isFirefox })
}

const finalRef = this.getFinalRef(this.ref)
if (finalRef && typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => this.forceUpdate())
this.resizeObserver.observe(finalRef)
this.mutationObserver = new MutationObserver(() => this.forceUpdate())
this.mutationObserver.observe(finalRef, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
'disabled',
'tabindex',
'hidden',
'href',
'contenteditable',
'type',
'class',
'style'
]
})
}
}

componentDidUpdate() {
this.props.makeStyles?.()
}

componentWillUnmount() {
this.resizeObserver?.disconnect()
this.mutationObserver?.disconnect()
}

getFinalRef(el: UIElement): Element | undefined {
if (!el) {
return undefined
Expand Down Expand Up @@ -131,6 +160,8 @@ class ModalBody extends Component<ModalBodyProps> {
(finalRef.scrollHeight ?? 0) -
(finalRef.getBoundingClientRect()?.height ?? 0)
) > 1
const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0
const needsTabIndex = hasScrollbar && !hasTabbableChildren
return (
<ModalContext.Consumer>
{(value) => (
Expand All @@ -144,8 +175,9 @@ class ModalBody extends Component<ModalBodyProps> {
as={as}
css={this.props.styles?.modalBody}
padding={spacing ? undefined : padding}
// check if there is a scrollbar, if so, the element has to be tabbable
{...(hasScrollbar
// check if there is a scrollbar and no focusable children, if so, the
// element has to be tabbable
{...(needsTabIndex
? {
tabIndex: 0,
'aria-label': value.bodyScrollAriaLabel
Expand Down
1 change: 1 addition & 0 deletions packages/ui-modal/src/Modal/v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ type: embed
<Figure.Item>Modals should be able to be closed by clicking away, esc key and/or a close button</Figure.Item>
<Figure.Item>We recommend that modals begin with a heading (typically H2)</Figure.Item>
<Figure.Item>The Modal's header currently becomes non-sticky when the window height is too small, improving navigation of the Modal.Body, e.g., at higher zoom levels</Figure.Item>
<Figure.Item>`Modal.Body` automatically becomes a keyboard tab stop (its `tabIndex` is set to `0`) when it is vertically scrollable and contains no focusable elements, so it can be scrolled by keyboard-only users. This is re-evaluated dynamically as the body's content or size changes.</Figure.Item>
</Figure>
</Guidelines>
```
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading