diff --git a/packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx b/packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx
index fd6438e985..e3d043efee 100644
--- a/packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx
+++ b/packages/ui-modal/src/Modal/__tests__/ModalBody.test.tsx
@@ -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'
@@ -114,4 +114,100 @@ describe('', () => {
}
})
})
+
+ describe('tab stop on a scrollable body', () => {
+ let scrollHeightSpy: ReturnType
+ let rectSpy: ReturnType
+ 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({BODY_TEXT})
+ 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(
+
+
+ {BODY_TEXT}
+
+ )
+ 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({BODY_TEXT})
+ 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(
+ {BODY_TEXT}
+ )
+ const body = await findByText(BODY_TEXT)
+ await waitFor(() => expect(body).toHaveAttribute('tabindex', '0'))
+
+ rerender(
+
+
+ {BODY_TEXT}
+
+ )
+
+ await waitFor(() => expect(body).not.toHaveAttribute('tabindex'))
+ })
+ })
})
diff --git a/packages/ui-modal/src/Modal/v1/ModalBody/index.tsx b/packages/ui-modal/src/Modal/v1/ModalBody/index.tsx
index 1241e4821d..7e66389037 100644
--- a/packages/ui-modal/src/Modal/v1/ModalBody/index.tsx
+++ b/packages/ui-modal/src/Modal/v1/ModalBody/index.tsx
@@ -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'
@@ -55,6 +55,8 @@ class ModalBody extends Component {
}
ref: UIElement | null = null
+ private resizeObserver?: ResizeObserver
+ private mutationObserver?: MutationObserver
handleRef = (el: UIElement | null) => {
const { elementRef } = this.props
@@ -86,12 +88,39 @@ class ModalBody extends Component {
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
@@ -124,6 +153,8 @@ class ModalBody extends Component {
(finalRef.scrollHeight ?? 0) -
(finalRef.getBoundingClientRect()?.height ?? 0)
) > 1
+ const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0
+ const needsTabIndex = hasScrollbar && !hasTabbableChildren
return (
{(value) => (
@@ -137,8 +168,9 @@ class ModalBody extends Component {
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
diff --git a/packages/ui-modal/src/Modal/v1/README.md b/packages/ui-modal/src/Modal/v1/README.md
index ade0e340c7..d940627ede 100644
--- a/packages/ui-modal/src/Modal/v1/README.md
+++ b/packages/ui-modal/src/Modal/v1/README.md
@@ -672,6 +672,7 @@ type: embed
Modals should be able to be closed by clicking away, esc key and/or a close button
We recommend that modals begin with a heading (typically H2)
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
+ `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.
```
diff --git a/packages/ui-modal/src/Modal/v2/ModalBody/index.tsx b/packages/ui-modal/src/Modal/v2/ModalBody/index.tsx
index 61f5200137..c4ecae0459 100644
--- a/packages/ui-modal/src/Modal/v2/ModalBody/index.tsx
+++ b/packages/ui-modal/src/Modal/v2/ModalBody/index.tsx
@@ -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'
@@ -54,6 +54,8 @@ class ModalBody extends Component {
}
ref: UIElement | null = null
+ private resizeObserver?: ResizeObserver
+ private mutationObserver?: MutationObserver
handleRef = (el: UIElement | null) => {
const { elementRef } = this.props
@@ -85,12 +87,39 @@ class ModalBody extends Component {
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
@@ -131,6 +160,8 @@ class ModalBody extends Component {
(finalRef.scrollHeight ?? 0) -
(finalRef.getBoundingClientRect()?.height ?? 0)
) > 1
+ const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0
+ const needsTabIndex = hasScrollbar && !hasTabbableChildren
return (
{(value) => (
@@ -144,8 +175,9 @@ class ModalBody extends Component {
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
diff --git a/packages/ui-modal/src/Modal/v2/README.md b/packages/ui-modal/src/Modal/v2/README.md
index 27e3f7d192..9ab31679d1 100644
--- a/packages/ui-modal/src/Modal/v2/README.md
+++ b/packages/ui-modal/src/Modal/v2/README.md
@@ -668,6 +668,7 @@ type: embed
Modals should be able to be closed by clicking away, esc key and/or a close button
We recommend that modals begin with a heading (typically H2)
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
+ `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.
```
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2d693a44cc..dfa32615c6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9205,8 +9205,8 @@ packages:
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'}
- enhanced-resolve@5.22.1:
- resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==}
+ enhanced-resolve@5.23.0:
+ resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==}
engines: {node: '>=10.13.0'}
enquirer@2.3.6:
@@ -18328,7 +18328,7 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
- enhanced-resolve@5.22.1:
+ enhanced-resolve@5.23.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
@@ -23553,7 +23553,7 @@ snapshots:
acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.28.2
chrome-trace-event: 1.0.4
- enhanced-resolve: 5.22.1
+ enhanced-resolve: 5.23.0
es-module-lexer: 2.1.0
eslint-scope: 5.1.1
events: 3.3.0
@@ -23592,7 +23592,7 @@ snapshots:
acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.28.2
chrome-trace-event: 1.0.4
- enhanced-resolve: 5.22.1
+ enhanced-resolve: 5.23.0
es-module-lexer: 2.1.0
eslint-scope: 5.1.1
events: 3.3.0