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
53 changes: 37 additions & 16 deletions packages/ui-modal/src/Modal/v1/ModalBody/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import generateStyle from './styles.js'
import generateComponentTheme from './theme.js'

import { allowedProps } from './props.js'
import type { ModalBodyProps } from './props'
import type { ModalBodyProps, ModalBodyState } from './props'
import { UIElement } from '@instructure/shared-types'
import ModalContext from '../ModalContext.js'

Expand All @@ -44,7 +44,7 @@ id: Modal.Body
---
**/
@withStyle(generateStyle, generateComponentTheme)
class ModalBody extends Component<ModalBodyProps> {
class ModalBody extends Component<ModalBodyProps, ModalBodyState> {
static displayName = 'ModalBody'
static readonly componentId = 'Modal.Body'

Expand All @@ -55,6 +55,11 @@ class ModalBody extends Component<ModalBodyProps> {
variant: 'default'
}

state: ModalBodyState = { isFirefox: false, needsTabIndex: false }
// mirrors state.needsTabIndex, but readable synchronously in the observer
// callbacks below, where a pending setState would make this.state stale
private lastNeedsTabIndex = false

ref: UIElement | null = null
private resizeObserver?: ResizeObserver
private mutationObserver?: MutationObserver
Expand Down Expand Up @@ -92,9 +97,10 @@ class ModalBody extends Component<ModalBodyProps> {

const finalRef = this.getFinalRef(this.ref)
if (finalRef && typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => this.forceUpdate())
this.syncTabIndex()
this.resizeObserver = new ResizeObserver(this.syncTabIndex)
this.resizeObserver.observe(finalRef)
this.mutationObserver = new MutationObserver(() => this.forceUpdate())
this.mutationObserver = new MutationObserver(this.syncTabIndex)
this.mutationObserver.observe(finalRef, {
childList: true,
subtree: true,
Expand Down Expand Up @@ -122,6 +128,32 @@ class ModalBody extends Component<ModalBodyProps> {
this.mutationObserver?.disconnect()
}

// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.
syncTabIndex = () => {
const finalRef = this.getFinalRef(this.ref)
const hasScrollbar =
!!finalRef &&
Math.abs(
(finalRef.scrollHeight ?? 0) -
(finalRef.getBoundingClientRect()?.height ?? 0)
) > 1
const needsTabIndex = hasScrollbar && findTabbable(finalRef).length === 0

if (needsTabIndex === this.lastNeedsTabIndex) return
this.lastNeedsTabIndex = needsTabIndex
this.setState({ needsTabIndex })
}

// this recursive function is needed because `ref` can be a React component.
// TODO rethink, the 'as' prop, likely its not a good idea to allow React
// components. See INSTUI-4674
getFinalRef(el: UIElement): Element | undefined {
if (!el) {
return undefined
Expand All @@ -144,18 +176,7 @@ class ModalBody extends Component<ModalBodyProps> {
ModalBody
)
const isFit = overflow === 'fit'
// this recursive function is needed because `ref` can be a React component.
// TODO rethink, the 'as' prop, likely its not a good idea to allow React
// components. See INSTUI-4674
const finalRef = this.getFinalRef(this.ref)
const hasScrollbar =
finalRef &&
Math.abs(
(finalRef.scrollHeight ?? 0) -
(finalRef.getBoundingClientRect()?.height ?? 0)
) > 1
const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0
const needsTabIndex = hasScrollbar && !hasTabbableChildren
const { needsTabIndex } = this.state
return (
<ModalContext.Consumer>
{(value) => (
Expand Down
1 change: 1 addition & 0 deletions packages/ui-modal/src/Modal/v1/ModalBody/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type ModalBodyStyle = ComponentStyle<'modalBody'>

type ModalBodyState = {
isFirefox: boolean
needsTabIndex: boolean
}
const allowedProps: AllowedPropKeys = [
'children',
Expand Down
53 changes: 37 additions & 16 deletions packages/ui-modal/src/Modal/v2/ModalBody/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { withStyleNew } from '@instructure/emotion'
import generateStyle from './styles.js'

import { allowedProps } from './props.js'
import type { ModalBodyProps } from './props'
import type { ModalBodyProps, ModalBodyState } from './props'
import { UIElement } from '@instructure/shared-types'
import ModalContext from '../ModalContext.js'

Expand All @@ -43,7 +43,7 @@ id: Modal.Body
---
**/
@withStyleNew(generateStyle, 'ModalBody')
class ModalBody extends Component<ModalBodyProps> {
class ModalBody extends Component<ModalBodyProps, ModalBodyState> {
static displayName = 'ModalBody'
static readonly componentId = 'Modal.Body'
static readonly themeId = 'ModalBody'
Expand All @@ -55,6 +55,11 @@ class ModalBody extends Component<ModalBodyProps> {
variant: 'default'
}

state: ModalBodyState = { isFirefox: false, needsTabIndex: false }
// mirrors state.needsTabIndex, but readable synchronously in the observer
// callbacks below, where a pending setState would make this.state stale
private lastNeedsTabIndex = false

ref: UIElement | null = null
private resizeObserver?: ResizeObserver
private mutationObserver?: MutationObserver
Expand Down Expand Up @@ -92,9 +97,10 @@ class ModalBody extends Component<ModalBodyProps> {

const finalRef = this.getFinalRef(this.ref)
if (finalRef && typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => this.forceUpdate())
this.syncTabIndex()
this.resizeObserver = new ResizeObserver(this.syncTabIndex)
this.resizeObserver.observe(finalRef)
this.mutationObserver = new MutationObserver(() => this.forceUpdate())
this.mutationObserver = new MutationObserver(this.syncTabIndex)
this.mutationObserver.observe(finalRef, {
childList: true,
subtree: true,
Expand Down Expand Up @@ -122,6 +128,32 @@ class ModalBody extends Component<ModalBodyProps> {
this.mutationObserver?.disconnect()
}

// The body is a tab stop only while it can be scrolled but holds nothing
// focusable. Both inputs come from the DOM, so the observers recompute them
// on resize and on subtree changes — which is most changes inside the body,
// the vast majority of them leaving the result identical. Comparing against
// the last computed value before calling setState keeps those callbacks from
// scheduling an update at all, rather than scheduling one React later
// discards: `setState` warns about updates outside `act()` in tests as soon
// as it schedules, so bailing out inside the updater would be too late.
Comment on lines +131 to +138

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.

please simplify to

  // The body is a tab stop only while it can be scrolled but holds nothing
  // focusable.

syncTabIndex = () => {
const finalRef = this.getFinalRef(this.ref)
const hasScrollbar =
!!finalRef &&
Math.abs(
(finalRef.scrollHeight ?? 0) -
(finalRef.getBoundingClientRect()?.height ?? 0)
) > 1
const needsTabIndex = hasScrollbar && findTabbable(finalRef).length === 0

if (needsTabIndex === this.lastNeedsTabIndex) return
this.lastNeedsTabIndex = needsTabIndex
this.setState({ needsTabIndex })
}

// this recursive function is needed because `ref` can be a React component.
// TODO rethink, the 'as' prop, likely its not a good idea to allow React
// components. See INSTUI-4674
getFinalRef(el: UIElement): Element | undefined {
if (!el) {
return undefined
Expand Down Expand Up @@ -152,18 +184,7 @@ class ModalBody extends Component<ModalBodyProps> {
ModalBody
)
const isFit = overflow === 'fit'
// this recursive function is needed because `ref` can be a React component.
// TODO rethink, the 'as' prop, likely its not a good idea to allow React
// components. See INSTUI-4674
const finalRef = this.getFinalRef(this.ref)
const hasScrollbar =
finalRef &&
Math.abs(
(finalRef.scrollHeight ?? 0) -
(finalRef.getBoundingClientRect()?.height ?? 0)
) > 1
const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0
const needsTabIndex = hasScrollbar && !hasTabbableChildren
const { needsTabIndex } = this.state
return (
<ModalContext.Consumer>
{(value) => (
Expand Down
1 change: 1 addition & 0 deletions packages/ui-modal/src/Modal/v2/ModalBody/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type ModalBodyStyle = ComponentStyle<'modalBody'>

type ModalBodyState = {
isFirefox: boolean
needsTabIndex: boolean
}
const allowedProps: AllowedPropKeys = [
'children',
Expand Down
Loading