@instructure/ui-modal 11.7.6 → 11.7.7-snapshot-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [11.7.7-snapshot-1](https://github.com/instructure/instructure-ui/compare/v11.7.6...v11.7.7-snapshot-1) (2026-09-18)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **ui-modal:** avoid redundant Modal.Body re-renders from its observers ([bccca5c](https://github.com/instructure/instructure-ui/commit/bccca5cc1f4bc10001a344b9142d2122a265a573))
11
+
12
+
6
13
  ## [11.7.6](https://github.com/instructure/instructure-ui/compare/v11.7.5...v11.7.6) (2026-09-14)
7
14
 
8
15
  **Note:** Version bump only for package @instructure/ui-modal
@@ -49,6 +49,13 @@ let ModalBody = (_dec = withStyle(generateStyle, generateComponentTheme), _dec(_
49
49
  as: 'div',
50
50
  variant: 'default'
51
51
  };
52
+ state = {
53
+ isFirefox: false,
54
+ needsTabIndex: false
55
+ };
56
+ // mirrors state.needsTabIndex, but readable synchronously in the observer
57
+ // callbacks below, where a pending setState would make this.state stale
58
+ lastNeedsTabIndex = false;
52
59
  ref = null;
53
60
  resizeObserver;
54
61
  mutationObserver;
@@ -77,9 +84,10 @@ let ModalBody = (_dec = withStyle(generateStyle, generateComponentTheme), _dec(_
77
84
  }
78
85
  const finalRef = this.getFinalRef(this.ref);
79
86
  if (finalRef && typeof ResizeObserver !== 'undefined') {
80
- this.resizeObserver = new ResizeObserver(() => this.forceUpdate());
87
+ this.syncTabIndex();
88
+ this.resizeObserver = new ResizeObserver(this.syncTabIndex);
81
89
  this.resizeObserver.observe(finalRef);
82
- this.mutationObserver = new MutationObserver(() => this.forceUpdate());
90
+ this.mutationObserver = new MutationObserver(this.syncTabIndex);
83
91
  this.mutationObserver.observe(finalRef, {
84
92
  childList: true,
85
93
  subtree: true,
@@ -90,11 +98,32 @@ let ModalBody = (_dec = withStyle(generateStyle, generateComponentTheme), _dec(_
90
98
  }
91
99
  componentDidUpdate() {
92
100
  this.props.makeStyles?.();
101
+ // The observers miss some changes, e.g. a text-only edit that makes the
102
+ // body overflow without resizing it. syncTabIndex returns early if
103
+ // nothing changed.
104
+ this.syncTabIndex();
93
105
  }
94
106
  componentWillUnmount() {
95
107
  this.resizeObserver?.disconnect();
96
108
  this.mutationObserver?.disconnect();
97
109
  }
110
+
111
+ // The body is a tab stop only while it can be scrolled but holds nothing
112
+ // focusable.
113
+ syncTabIndex = () => {
114
+ const finalRef = this.getFinalRef(this.ref);
115
+ const hasScrollbar = !!finalRef && Math.abs((finalRef.scrollHeight ?? 0) - (finalRef.getBoundingClientRect()?.height ?? 0)) > 1;
116
+ const needsTabIndex = hasScrollbar && findTabbable(finalRef).length === 0;
117
+ if (needsTabIndex === this.lastNeedsTabIndex) return;
118
+ this.lastNeedsTabIndex = needsTabIndex;
119
+ this.setState({
120
+ needsTabIndex
121
+ });
122
+ };
123
+
124
+ // this recursive function is needed because `ref` can be a React component.
125
+ // TODO rethink, the 'as' prop, likely its not a good idea to allow React
126
+ // components. See INSTUI-4674
98
127
  getFinalRef(el) {
99
128
  if (!el) {
100
129
  return undefined;
@@ -119,13 +148,9 @@ let ModalBody = (_dec = withStyle(generateStyle, generateComponentTheme), _dec(_
119
148
  } = this.props;
120
149
  const passthroughProps = View.omitViewProps(omitProps(rest, ModalBody.allowedProps), ModalBody);
121
150
  const isFit = overflow === 'fit';
122
- // this recursive function is needed because `ref` can be a React component.
123
- // TODO rethink, the 'as' prop, likely its not a good idea to allow React
124
- // components. See INSTUI-4674
125
- const finalRef = this.getFinalRef(this.ref);
126
- const hasScrollbar = finalRef && Math.abs((finalRef.scrollHeight ?? 0) - (finalRef.getBoundingClientRect()?.height ?? 0)) > 1;
127
- const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0;
128
- const needsTabIndex = hasScrollbar && !hasTabbableChildren;
151
+ const {
152
+ needsTabIndex
153
+ } = this.state;
129
154
  return _jsx(ModalContext.Consumer, {
130
155
  children: value => _jsx(View, {
131
156
  ...passthroughProps,
@@ -49,6 +49,13 @@ let ModalBody = (_dec = withStyleNew(generateStyle, 'ModalBody'), _dec(_class =
49
49
  as: 'div',
50
50
  variant: 'default'
51
51
  };
52
+ state = {
53
+ isFirefox: false,
54
+ needsTabIndex: false
55
+ };
56
+ // mirrors state.needsTabIndex, but readable synchronously in the observer
57
+ // callbacks below, where a pending setState would make this.state stale
58
+ lastNeedsTabIndex = false;
52
59
  ref = null;
53
60
  resizeObserver;
54
61
  mutationObserver;
@@ -77,9 +84,10 @@ let ModalBody = (_dec = withStyleNew(generateStyle, 'ModalBody'), _dec(_class =
77
84
  }
78
85
  const finalRef = this.getFinalRef(this.ref);
79
86
  if (finalRef && typeof ResizeObserver !== 'undefined') {
80
- this.resizeObserver = new ResizeObserver(() => this.forceUpdate());
87
+ this.syncTabIndex();
88
+ this.resizeObserver = new ResizeObserver(this.syncTabIndex);
81
89
  this.resizeObserver.observe(finalRef);
82
- this.mutationObserver = new MutationObserver(() => this.forceUpdate());
90
+ this.mutationObserver = new MutationObserver(this.syncTabIndex);
83
91
  this.mutationObserver.observe(finalRef, {
84
92
  childList: true,
85
93
  subtree: true,
@@ -90,11 +98,32 @@ let ModalBody = (_dec = withStyleNew(generateStyle, 'ModalBody'), _dec(_class =
90
98
  }
91
99
  componentDidUpdate() {
92
100
  this.props.makeStyles?.();
101
+ // The observers miss some changes, e.g. a text-only edit that makes the
102
+ // body overflow without resizing it. syncTabIndex returns early if
103
+ // nothing changed.
104
+ this.syncTabIndex();
93
105
  }
94
106
  componentWillUnmount() {
95
107
  this.resizeObserver?.disconnect();
96
108
  this.mutationObserver?.disconnect();
97
109
  }
110
+
111
+ // The body is a tab stop only while it can be scrolled but holds nothing
112
+ // focusable.
113
+ syncTabIndex = () => {
114
+ const finalRef = this.getFinalRef(this.ref);
115
+ const hasScrollbar = !!finalRef && Math.abs((finalRef.scrollHeight ?? 0) - (finalRef.getBoundingClientRect()?.height ?? 0)) > 1;
116
+ const needsTabIndex = hasScrollbar && findTabbable(finalRef).length === 0;
117
+ if (needsTabIndex === this.lastNeedsTabIndex) return;
118
+ this.lastNeedsTabIndex = needsTabIndex;
119
+ this.setState({
120
+ needsTabIndex
121
+ });
122
+ };
123
+
124
+ // this recursive function is needed because `ref` can be a React component.
125
+ // TODO rethink, the 'as' prop, likely its not a good idea to allow React
126
+ // components. See INSTUI-4674
98
127
  getFinalRef(el) {
99
128
  if (!el) {
100
129
  return undefined;
@@ -120,13 +149,9 @@ let ModalBody = (_dec = withStyleNew(generateStyle, 'ModalBody'), _dec(_class =
120
149
  } = this.props;
121
150
  const passthroughProps = View.omitViewProps(omitProps(rest, ModalBody.allowedProps), ModalBody);
122
151
  const isFit = overflow === 'fit';
123
- // this recursive function is needed because `ref` can be a React component.
124
- // TODO rethink, the 'as' prop, likely its not a good idea to allow React
125
- // components. See INSTUI-4674
126
- const finalRef = this.getFinalRef(this.ref);
127
- const hasScrollbar = finalRef && Math.abs((finalRef.scrollHeight ?? 0) - (finalRef.getBoundingClientRect()?.height ?? 0)) > 1;
128
- const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0;
129
- const needsTabIndex = hasScrollbar && !hasTabbableChildren;
152
+ const {
153
+ needsTabIndex
154
+ } = this.state;
130
155
  return _jsx(ModalContext.Consumer, {
131
156
  children: value => _jsx(View, {
132
157
  ...passthroughProps,
@@ -55,6 +55,13 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyle)(_styles.defa
55
55
  as: 'div',
56
56
  variant: 'default'
57
57
  };
58
+ state = {
59
+ isFirefox: false,
60
+ needsTabIndex: false
61
+ };
62
+ // mirrors state.needsTabIndex, but readable synchronously in the observer
63
+ // callbacks below, where a pending setState would make this.state stale
64
+ lastNeedsTabIndex = false;
58
65
  ref = null;
59
66
  resizeObserver;
60
67
  mutationObserver;
@@ -83,9 +90,10 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyle)(_styles.defa
83
90
  }
84
91
  const finalRef = this.getFinalRef(this.ref);
85
92
  if (finalRef && typeof ResizeObserver !== 'undefined') {
86
- this.resizeObserver = new ResizeObserver(() => this.forceUpdate());
93
+ this.syncTabIndex();
94
+ this.resizeObserver = new ResizeObserver(this.syncTabIndex);
87
95
  this.resizeObserver.observe(finalRef);
88
- this.mutationObserver = new MutationObserver(() => this.forceUpdate());
96
+ this.mutationObserver = new MutationObserver(this.syncTabIndex);
89
97
  this.mutationObserver.observe(finalRef, {
90
98
  childList: true,
91
99
  subtree: true,
@@ -96,11 +104,32 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyle)(_styles.defa
96
104
  }
97
105
  componentDidUpdate() {
98
106
  this.props.makeStyles?.();
107
+ // The observers miss some changes, e.g. a text-only edit that makes the
108
+ // body overflow without resizing it. syncTabIndex returns early if
109
+ // nothing changed.
110
+ this.syncTabIndex();
99
111
  }
100
112
  componentWillUnmount() {
101
113
  this.resizeObserver?.disconnect();
102
114
  this.mutationObserver?.disconnect();
103
115
  }
116
+
117
+ // The body is a tab stop only while it can be scrolled but holds nothing
118
+ // focusable.
119
+ syncTabIndex = () => {
120
+ const finalRef = this.getFinalRef(this.ref);
121
+ const hasScrollbar = !!finalRef && Math.abs((finalRef.scrollHeight ?? 0) - (finalRef.getBoundingClientRect()?.height ?? 0)) > 1;
122
+ const needsTabIndex = hasScrollbar && (0, _findTabbable.findTabbable)(finalRef).length === 0;
123
+ if (needsTabIndex === this.lastNeedsTabIndex) return;
124
+ this.lastNeedsTabIndex = needsTabIndex;
125
+ this.setState({
126
+ needsTabIndex
127
+ });
128
+ };
129
+
130
+ // this recursive function is needed because `ref` can be a React component.
131
+ // TODO rethink, the 'as' prop, likely its not a good idea to allow React
132
+ // components. See INSTUI-4674
104
133
  getFinalRef(el) {
105
134
  if (!el) {
106
135
  return undefined;
@@ -125,13 +154,9 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyle)(_styles.defa
125
154
  } = this.props;
126
155
  const passthroughProps = _v11_.View.omitViewProps((0, _omitProps.omitProps)(rest, ModalBody.allowedProps), ModalBody);
127
156
  const isFit = overflow === 'fit';
128
- // this recursive function is needed because `ref` can be a React component.
129
- // TODO rethink, the 'as' prop, likely its not a good idea to allow React
130
- // components. See INSTUI-4674
131
- const finalRef = this.getFinalRef(this.ref);
132
- const hasScrollbar = finalRef && Math.abs((finalRef.scrollHeight ?? 0) - (finalRef.getBoundingClientRect()?.height ?? 0)) > 1;
133
- const hasTabbableChildren = !!finalRef && (0, _findTabbable.findTabbable)(finalRef).length > 0;
134
- const needsTabIndex = hasScrollbar && !hasTabbableChildren;
157
+ const {
158
+ needsTabIndex
159
+ } = this.state;
135
160
  return (0, _jsxRuntime.jsx)(_ModalContext.default.Consumer, {
136
161
  children: value => (0, _jsxRuntime.jsx)(_v11_.View, {
137
162
  ...passthroughProps,
@@ -55,6 +55,13 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyleNew)(_styles.d
55
55
  as: 'div',
56
56
  variant: 'default'
57
57
  };
58
+ state = {
59
+ isFirefox: false,
60
+ needsTabIndex: false
61
+ };
62
+ // mirrors state.needsTabIndex, but readable synchronously in the observer
63
+ // callbacks below, where a pending setState would make this.state stale
64
+ lastNeedsTabIndex = false;
58
65
  ref = null;
59
66
  resizeObserver;
60
67
  mutationObserver;
@@ -83,9 +90,10 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyleNew)(_styles.d
83
90
  }
84
91
  const finalRef = this.getFinalRef(this.ref);
85
92
  if (finalRef && typeof ResizeObserver !== 'undefined') {
86
- this.resizeObserver = new ResizeObserver(() => this.forceUpdate());
93
+ this.syncTabIndex();
94
+ this.resizeObserver = new ResizeObserver(this.syncTabIndex);
87
95
  this.resizeObserver.observe(finalRef);
88
- this.mutationObserver = new MutationObserver(() => this.forceUpdate());
96
+ this.mutationObserver = new MutationObserver(this.syncTabIndex);
89
97
  this.mutationObserver.observe(finalRef, {
90
98
  childList: true,
91
99
  subtree: true,
@@ -96,11 +104,32 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyleNew)(_styles.d
96
104
  }
97
105
  componentDidUpdate() {
98
106
  this.props.makeStyles?.();
107
+ // The observers miss some changes, e.g. a text-only edit that makes the
108
+ // body overflow without resizing it. syncTabIndex returns early if
109
+ // nothing changed.
110
+ this.syncTabIndex();
99
111
  }
100
112
  componentWillUnmount() {
101
113
  this.resizeObserver?.disconnect();
102
114
  this.mutationObserver?.disconnect();
103
115
  }
116
+
117
+ // The body is a tab stop only while it can be scrolled but holds nothing
118
+ // focusable.
119
+ syncTabIndex = () => {
120
+ const finalRef = this.getFinalRef(this.ref);
121
+ const hasScrollbar = !!finalRef && Math.abs((finalRef.scrollHeight ?? 0) - (finalRef.getBoundingClientRect()?.height ?? 0)) > 1;
122
+ const needsTabIndex = hasScrollbar && (0, _findTabbable.findTabbable)(finalRef).length === 0;
123
+ if (needsTabIndex === this.lastNeedsTabIndex) return;
124
+ this.lastNeedsTabIndex = needsTabIndex;
125
+ this.setState({
126
+ needsTabIndex
127
+ });
128
+ };
129
+
130
+ // this recursive function is needed because `ref` can be a React component.
131
+ // TODO rethink, the 'as' prop, likely its not a good idea to allow React
132
+ // components. See INSTUI-4674
104
133
  getFinalRef(el) {
105
134
  if (!el) {
106
135
  return undefined;
@@ -126,13 +155,9 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyleNew)(_styles.d
126
155
  } = this.props;
127
156
  const passthroughProps = _latest.View.omitViewProps((0, _omitProps.omitProps)(rest, ModalBody.allowedProps), ModalBody);
128
157
  const isFit = overflow === 'fit';
129
- // this recursive function is needed because `ref` can be a React component.
130
- // TODO rethink, the 'as' prop, likely its not a good idea to allow React
131
- // components. See INSTUI-4674
132
- const finalRef = this.getFinalRef(this.ref);
133
- const hasScrollbar = finalRef && Math.abs((finalRef.scrollHeight ?? 0) - (finalRef.getBoundingClientRect()?.height ?? 0)) > 1;
134
- const hasTabbableChildren = !!finalRef && (0, _findTabbable.findTabbable)(finalRef).length > 0;
135
- const needsTabIndex = hasScrollbar && !hasTabbableChildren;
158
+ const {
159
+ needsTabIndex
160
+ } = this.state;
136
161
  return (0, _jsxRuntime.jsx)(_ModalContext.default.Consumer, {
137
162
  children: value => (0, _jsxRuntime.jsx)(_latest.View, {
138
163
  ...passthroughProps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instructure/ui-modal",
3
- "version": "11.7.6",
3
+ "version": "11.7.7-snapshot-1",
4
4
  "description": "A component for displaying content in a dialog overlay",
5
5
  "author": "Instructure, Inc. Engineering and Product Design",
6
6
  "module": "./es/index.js",
@@ -15,26 +15,26 @@
15
15
  "license": "MIT",
16
16
  "dependencies": {
17
17
  "@babel/runtime": "^7.29.7",
18
- "@instructure/console": "11.7.6",
19
- "@instructure/emotion": "11.7.6",
20
- "@instructure/ui-buttons": "11.7.6",
21
- "@instructure/ui-dialog": "11.7.6",
22
- "@instructure/shared-types": "11.7.6",
23
- "@instructure/ui-dom-utils": "11.7.6",
24
- "@instructure/ui-motion": "11.7.6",
25
- "@instructure/ui-overlays": "11.7.6",
26
- "@instructure/ui-portal": "11.7.6",
27
- "@instructure/ui-react-utils": "11.7.6",
28
- "@instructure/ui-themes": "11.7.6",
29
- "@instructure/ui-view": "11.7.6",
30
- "@instructure/ui-utils": "11.7.6"
18
+ "@instructure/console": "11.7.7-snapshot-1",
19
+ "@instructure/emotion": "11.7.7-snapshot-1",
20
+ "@instructure/shared-types": "11.7.7-snapshot-1",
21
+ "@instructure/ui-buttons": "11.7.7-snapshot-1",
22
+ "@instructure/ui-dialog": "11.7.7-snapshot-1",
23
+ "@instructure/ui-dom-utils": "11.7.7-snapshot-1",
24
+ "@instructure/ui-motion": "11.7.7-snapshot-1",
25
+ "@instructure/ui-overlays": "11.7.7-snapshot-1",
26
+ "@instructure/ui-portal": "11.7.7-snapshot-1",
27
+ "@instructure/ui-view": "11.7.7-snapshot-1",
28
+ "@instructure/ui-themes": "11.7.7-snapshot-1",
29
+ "@instructure/ui-utils": "11.7.7-snapshot-1",
30
+ "@instructure/ui-react-utils": "11.7.7-snapshot-1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "vitest": "^5.0.0",
34
- "@instructure/ui-position": "11.7.6",
35
- "@instructure/ui-babel-preset": "11.7.6",
36
- "@instructure/ui-color-utils": "11.7.6",
37
- "@instructure/ui-tooltip": "11.7.6"
34
+ "@instructure/ui-position": "11.7.7-snapshot-1",
35
+ "@instructure/ui-babel-preset": "11.7.7-snapshot-1",
36
+ "@instructure/ui-tooltip": "11.7.7-snapshot-1",
37
+ "@instructure/ui-color-utils": "11.7.7-snapshot-1"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": ">=18 <=19"
@@ -33,7 +33,7 @@ import generateStyle from './styles.js'
33
33
  import generateComponentTheme from './theme.js'
34
34
 
35
35
  import { allowedProps } from './props.js'
36
- import type { ModalBodyProps } from './props'
36
+ import type { ModalBodyProps, ModalBodyState } from './props'
37
37
  import { UIElement } from '@instructure/shared-types'
38
38
  import ModalContext from '../ModalContext.js'
39
39
 
@@ -44,7 +44,7 @@ id: Modal.Body
44
44
  ---
45
45
  **/
46
46
  @withStyle(generateStyle, generateComponentTheme)
47
- class ModalBody extends Component<ModalBodyProps> {
47
+ class ModalBody extends Component<ModalBodyProps, ModalBodyState> {
48
48
  static displayName = 'ModalBody'
49
49
  static readonly componentId = 'Modal.Body'
50
50
 
@@ -55,6 +55,11 @@ class ModalBody extends Component<ModalBodyProps> {
55
55
  variant: 'default'
56
56
  }
57
57
 
58
+ state: ModalBodyState = { isFirefox: false, needsTabIndex: false }
59
+ // mirrors state.needsTabIndex, but readable synchronously in the observer
60
+ // callbacks below, where a pending setState would make this.state stale
61
+ private lastNeedsTabIndex = false
62
+
58
63
  ref: UIElement | null = null
59
64
  private resizeObserver?: ResizeObserver
60
65
  private mutationObserver?: MutationObserver
@@ -92,9 +97,10 @@ class ModalBody extends Component<ModalBodyProps> {
92
97
 
93
98
  const finalRef = this.getFinalRef(this.ref)
94
99
  if (finalRef && typeof ResizeObserver !== 'undefined') {
95
- this.resizeObserver = new ResizeObserver(() => this.forceUpdate())
100
+ this.syncTabIndex()
101
+ this.resizeObserver = new ResizeObserver(this.syncTabIndex)
96
102
  this.resizeObserver.observe(finalRef)
97
- this.mutationObserver = new MutationObserver(() => this.forceUpdate())
103
+ this.mutationObserver = new MutationObserver(this.syncTabIndex)
98
104
  this.mutationObserver.observe(finalRef, {
99
105
  childList: true,
100
106
  subtree: true,
@@ -115,6 +121,10 @@ class ModalBody extends Component<ModalBodyProps> {
115
121
 
116
122
  componentDidUpdate() {
117
123
  this.props.makeStyles?.()
124
+ // The observers miss some changes, e.g. a text-only edit that makes the
125
+ // body overflow without resizing it. syncTabIndex returns early if
126
+ // nothing changed.
127
+ this.syncTabIndex()
118
128
  }
119
129
 
120
130
  componentWillUnmount() {
@@ -122,6 +132,26 @@ class ModalBody extends Component<ModalBodyProps> {
122
132
  this.mutationObserver?.disconnect()
123
133
  }
124
134
 
135
+ // The body is a tab stop only while it can be scrolled but holds nothing
136
+ // focusable.
137
+ syncTabIndex = () => {
138
+ const finalRef = this.getFinalRef(this.ref)
139
+ const hasScrollbar =
140
+ !!finalRef &&
141
+ Math.abs(
142
+ (finalRef.scrollHeight ?? 0) -
143
+ (finalRef.getBoundingClientRect()?.height ?? 0)
144
+ ) > 1
145
+ const needsTabIndex = hasScrollbar && findTabbable(finalRef).length === 0
146
+
147
+ if (needsTabIndex === this.lastNeedsTabIndex) return
148
+ this.lastNeedsTabIndex = needsTabIndex
149
+ this.setState({ needsTabIndex })
150
+ }
151
+
152
+ // this recursive function is needed because `ref` can be a React component.
153
+ // TODO rethink, the 'as' prop, likely its not a good idea to allow React
154
+ // components. See INSTUI-4674
125
155
  getFinalRef(el: UIElement): Element | undefined {
126
156
  if (!el) {
127
157
  return undefined
@@ -144,18 +174,7 @@ class ModalBody extends Component<ModalBodyProps> {
144
174
  ModalBody
145
175
  )
146
176
  const isFit = overflow === 'fit'
147
- // this recursive function is needed because `ref` can be a React component.
148
- // TODO rethink, the 'as' prop, likely its not a good idea to allow React
149
- // components. See INSTUI-4674
150
- const finalRef = this.getFinalRef(this.ref)
151
- const hasScrollbar =
152
- finalRef &&
153
- Math.abs(
154
- (finalRef.scrollHeight ?? 0) -
155
- (finalRef.getBoundingClientRect()?.height ?? 0)
156
- ) > 1
157
- const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0
158
- const needsTabIndex = hasScrollbar && !hasTabbableChildren
177
+ const { needsTabIndex } = this.state
159
178
  return (
160
179
  <ModalContext.Consumer>
161
180
  {(value) => (
@@ -56,6 +56,7 @@ type ModalBodyStyle = ComponentStyle<'modalBody'>
56
56
 
57
57
  type ModalBodyState = {
58
58
  isFirefox: boolean
59
+ needsTabIndex: boolean
59
60
  }
60
61
  const allowedProps: AllowedPropKeys = [
61
62
  'children',
@@ -32,7 +32,7 @@ import { withStyleNew } from '@instructure/emotion'
32
32
  import generateStyle from './styles.js'
33
33
 
34
34
  import { allowedProps } from './props.js'
35
- import type { ModalBodyProps } from './props'
35
+ import type { ModalBodyProps, ModalBodyState } from './props'
36
36
  import { UIElement } from '@instructure/shared-types'
37
37
  import ModalContext from '../ModalContext.js'
38
38
 
@@ -43,7 +43,7 @@ id: Modal.Body
43
43
  ---
44
44
  **/
45
45
  @withStyleNew(generateStyle, 'ModalBody')
46
- class ModalBody extends Component<ModalBodyProps> {
46
+ class ModalBody extends Component<ModalBodyProps, ModalBodyState> {
47
47
  static displayName = 'ModalBody'
48
48
  static readonly componentId = 'Modal.Body'
49
49
  static readonly themeId = 'ModalBody'
@@ -55,6 +55,11 @@ class ModalBody extends Component<ModalBodyProps> {
55
55
  variant: 'default'
56
56
  }
57
57
 
58
+ state: ModalBodyState = { isFirefox: false, needsTabIndex: false }
59
+ // mirrors state.needsTabIndex, but readable synchronously in the observer
60
+ // callbacks below, where a pending setState would make this.state stale
61
+ private lastNeedsTabIndex = false
62
+
58
63
  ref: UIElement | null = null
59
64
  private resizeObserver?: ResizeObserver
60
65
  private mutationObserver?: MutationObserver
@@ -92,9 +97,10 @@ class ModalBody extends Component<ModalBodyProps> {
92
97
 
93
98
  const finalRef = this.getFinalRef(this.ref)
94
99
  if (finalRef && typeof ResizeObserver !== 'undefined') {
95
- this.resizeObserver = new ResizeObserver(() => this.forceUpdate())
100
+ this.syncTabIndex()
101
+ this.resizeObserver = new ResizeObserver(this.syncTabIndex)
96
102
  this.resizeObserver.observe(finalRef)
97
- this.mutationObserver = new MutationObserver(() => this.forceUpdate())
103
+ this.mutationObserver = new MutationObserver(this.syncTabIndex)
98
104
  this.mutationObserver.observe(finalRef, {
99
105
  childList: true,
100
106
  subtree: true,
@@ -115,6 +121,10 @@ class ModalBody extends Component<ModalBodyProps> {
115
121
 
116
122
  componentDidUpdate() {
117
123
  this.props.makeStyles?.()
124
+ // The observers miss some changes, e.g. a text-only edit that makes the
125
+ // body overflow without resizing it. syncTabIndex returns early if
126
+ // nothing changed.
127
+ this.syncTabIndex()
118
128
  }
119
129
 
120
130
  componentWillUnmount() {
@@ -122,6 +132,26 @@ class ModalBody extends Component<ModalBodyProps> {
122
132
  this.mutationObserver?.disconnect()
123
133
  }
124
134
 
135
+ // The body is a tab stop only while it can be scrolled but holds nothing
136
+ // focusable.
137
+ syncTabIndex = () => {
138
+ const finalRef = this.getFinalRef(this.ref)
139
+ const hasScrollbar =
140
+ !!finalRef &&
141
+ Math.abs(
142
+ (finalRef.scrollHeight ?? 0) -
143
+ (finalRef.getBoundingClientRect()?.height ?? 0)
144
+ ) > 1
145
+ const needsTabIndex = hasScrollbar && findTabbable(finalRef).length === 0
146
+
147
+ if (needsTabIndex === this.lastNeedsTabIndex) return
148
+ this.lastNeedsTabIndex = needsTabIndex
149
+ this.setState({ needsTabIndex })
150
+ }
151
+
152
+ // this recursive function is needed because `ref` can be a React component.
153
+ // TODO rethink, the 'as' prop, likely its not a good idea to allow React
154
+ // components. See INSTUI-4674
125
155
  getFinalRef(el: UIElement): Element | undefined {
126
156
  if (!el) {
127
157
  return undefined
@@ -152,18 +182,7 @@ class ModalBody extends Component<ModalBodyProps> {
152
182
  ModalBody
153
183
  )
154
184
  const isFit = overflow === 'fit'
155
- // this recursive function is needed because `ref` can be a React component.
156
- // TODO rethink, the 'as' prop, likely its not a good idea to allow React
157
- // components. See INSTUI-4674
158
- const finalRef = this.getFinalRef(this.ref)
159
- const hasScrollbar =
160
- finalRef &&
161
- Math.abs(
162
- (finalRef.scrollHeight ?? 0) -
163
- (finalRef.getBoundingClientRect()?.height ?? 0)
164
- ) > 1
165
- const hasTabbableChildren = !!finalRef && findTabbable(finalRef).length > 0
166
- const needsTabIndex = hasScrollbar && !hasTabbableChildren
185
+ const { needsTabIndex } = this.state
167
186
  return (
168
187
  <ModalContext.Consumer>
169
188
  {(value) => (
@@ -62,6 +62,7 @@ type ModalBodyStyle = ComponentStyle<'modalBody'>
62
62
 
63
63
  type ModalBodyState = {
64
64
  isFirefox: boolean
65
+ needsTabIndex: boolean
65
66
  }
66
67
  const allowedProps: AllowedPropKeys = [
67
68
  'children',