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