diff --git a/packages/ui-dom-utils/src/findTabbable.ts b/packages/ui-dom-utils/src/findTabbable.ts
index 3facbb1770..831b8204f5 100644
--- a/packages/ui-dom-utils/src/findTabbable.ts
+++ b/packages/ui-dom-utils/src/findTabbable.ts
@@ -41,6 +41,10 @@ import { UIElement } from '@instructure/shared-types'
* @param { boolean } shouldSearchRootNode - should the root node be included in the search
* @returns { Array } array of all tabbable children
*/
+// Attributes that affect tabbability:
+// tabindex, href, type, contenteditable, disabled - read directly by findFocusable
+// class, style, hidden - read indirectly, via the computed display/position
+// (visibility) checks in findFocusable
function findTabbable(el?: UIElement, shouldSearchRootNode?: boolean) {
return findFocusable(
el,
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 ff71041db7..974db84537 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.js'
@@ -56,6 +56,8 @@ class ModalBody extends Component {
}
ref: UIElement | null = null
+ private resizeObserver?: ResizeObserver
+ private mutationObserver?: MutationObserver
handleRef = (el: UIElement | null) => {
const { elementRef } = this.props
@@ -87,12 +89,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
@@ -125,6 +154,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) => (
@@ -138,8 +169,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 40e481609e..9e4ffb5fc4 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.js'
@@ -56,6 +56,8 @@ class ModalBody extends Component {
}
ref: UIElement | null = null
+ private resizeObserver?: ResizeObserver
+ private mutationObserver?: MutationObserver
handleRef = (el: UIElement | null) => {
const { elementRef } = this.props
@@ -87,12 +89,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
@@ -133,6 +162,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) => (
@@ -146,8 +177,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.
```