@instructure/ui-modal 10.29.0 → 10.30.0

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,17 @@
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
+ # [10.30.0](https://github.com/instructure/instructure-ui/compare/v10.29.0...v10.30.0) (2026-02-18)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ui-modal:** voiceOver in Chrome treats scrollable modal body as single interactive object preventing line-by-line navigation ([e387644](https://github.com/instructure/instructure-ui/commit/e387644fd79bd95ebdf197ce05887572a67081a6))
12
+
13
+
14
+
15
+
16
+
6
17
  # [10.29.0](https://github.com/instructure/instructure-ui/compare/v10.26.4...v10.29.0) (2026-01-14)
7
18
 
8
19
  **Note:** Version bump only for package @instructure/ui-modal
@@ -34,13 +34,15 @@ import { withStyle } from '@instructure/emotion';
34
34
  import generateStyle from './styles';
35
35
  import generateComponentTheme from './theme';
36
36
  import { propTypes, allowedProps } from './props';
37
- import { jsx as _jsx } from "@emotion/react/jsx-runtime";
37
+ import ModalContext from '../ModalContext';
38
+
38
39
  /**
39
40
  ---
40
41
  parent: Modal
41
42
  id: Modal.Body
42
43
  ---
43
44
  **/
45
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
44
46
  let ModalBody = (_dec = withStyle(generateStyle, generateComponentTheme), _dec2 = testable(), _dec(_class = _dec2(_class = (_ModalBody = class ModalBody extends Component {
45
47
  constructor(props) {
46
48
  super(props);
@@ -83,7 +85,7 @@ let ModalBody = (_dec = withStyle(generateStyle, generateComponentTheme), _dec2
83
85
  return void 0;
84
86
  }
85
87
  render() {
86
- var _this$props$styles, _finalRef$scrollHeigh, _finalRef$getBounding, _finalRef$getBounding2;
88
+ var _finalRef$scrollHeigh, _finalRef$getBounding, _finalRef$getBounding2;
87
89
  const _this$props3 = this.props,
88
90
  as = _this$props3.as,
89
91
  elementRef = _this$props3.elementRef,
@@ -98,22 +100,28 @@ let ModalBody = (_dec = withStyle(generateStyle, generateComponentTheme), _dec2
98
100
  // TODO rethink, the 'as' prop, likely its not a good idea to allow React
99
101
  // components. See INSTUI-4674
100
102
  const finalRef = this.getFinalRef(this.ref);
101
- return _jsx(View, {
102
- ...passthroughProps,
103
- display: "block",
104
- width: isFit ? '100%' : void 0,
105
- height: isFit ? '100%' : void 0,
106
- elementRef: this.handleRef,
107
- as: as,
108
- css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.modalBody,
109
- padding: padding
110
- // check if there is a scrollbar, if so, the element has to be tabbable to be able to scroll with keyboard only
111
- // epsilon tolerance is used to avoid false positives, this is generally safer than Math rounding techniques
112
- ,
113
- ...(finalRef && Math.abs(((_finalRef$scrollHeigh = finalRef.scrollHeight) !== null && _finalRef$scrollHeigh !== void 0 ? _finalRef$scrollHeigh : 0) - ((_finalRef$getBounding = (_finalRef$getBounding2 = finalRef.getBoundingClientRect()) === null || _finalRef$getBounding2 === void 0 ? void 0 : _finalRef$getBounding2.height) !== null && _finalRef$getBounding !== void 0 ? _finalRef$getBounding : 0)) > 0.05 ? {
114
- tabIndex: 0
115
- } : {}),
116
- children: children
103
+ const hasScrollbar = finalRef && Math.abs(((_finalRef$scrollHeigh = finalRef.scrollHeight) !== null && _finalRef$scrollHeigh !== void 0 ? _finalRef$scrollHeigh : 0) - ((_finalRef$getBounding = (_finalRef$getBounding2 = finalRef.getBoundingClientRect()) === null || _finalRef$getBounding2 === void 0 ? void 0 : _finalRef$getBounding2.height) !== null && _finalRef$getBounding !== void 0 ? _finalRef$getBounding : 0)) > 1;
104
+ return _jsx(ModalContext.Consumer, {
105
+ children: value => {
106
+ var _this$props$styles;
107
+ return _jsx(View, {
108
+ ...passthroughProps,
109
+ display: "block",
110
+ width: isFit ? '100%' : void 0,
111
+ height: isFit ? '100%' : void 0,
112
+ elementRef: this.handleRef,
113
+ as: as,
114
+ css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.modalBody,
115
+ padding: padding
116
+ // check if there is a scrollbar, if so, the element has to be tabbable
117
+ ,
118
+ ...(hasScrollbar ? {
119
+ tabIndex: 0,
120
+ 'aria-label': value.bodyScrollAriaLabel
121
+ } : {}),
122
+ children: children
123
+ });
124
+ }
117
125
  });
118
126
  }
119
127
  }, _ModalBody.displayName = "ModalBody", _ModalBody.componentId = 'Modal.Body', _ModalBody.propTypes = propTypes, _ModalBody.allowedProps = allowedProps, _ModalBody.defaultProps = {
@@ -0,0 +1,34 @@
1
+ /*
2
+ * The MIT License (MIT)
3
+ *
4
+ * Copyright (c) 2015 - present Instructure, Inc.
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ import { createContext } from 'react';
26
+ /**
27
+ * React context created by the `Modal` component
28
+ * @private
29
+ */
30
+ const ModalContext = /*#__PURE__*/createContext({
31
+ bodyScrollAriaLabel: void 0
32
+ });
33
+ export default ModalContext;
34
+ export { ModalContext };
@@ -33,6 +33,7 @@ import { CloseButton } from '@instructure/ui-buttons';
33
33
  import generateStyle from './styles';
34
34
  import generateComponentTheme from './theme';
35
35
  import { propTypes, allowedProps } from './props';
36
+ import ModalContext from '../ModalContext';
36
37
  import { jsx as _jsx } from "@emotion/react/jsx-runtime";
37
38
  /**
38
39
  ---
@@ -44,8 +45,30 @@ let ModalHeader = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
44
45
  constructor(...args) {
45
46
  super(...args);
46
47
  this.ref = null;
48
+ /**
49
+ * Gets all text in a DOM subtree, text under <button> nodes is excluded
50
+ */
51
+ this.getTextExcludingButtons = root => {
52
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
53
+ acceptNode(node) {
54
+ var _node$parentElement;
55
+ return (_node$parentElement = node.parentElement) !== null && _node$parentElement !== void 0 && _node$parentElement.closest('button') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
56
+ }
57
+ });
58
+ let text = '';
59
+ let current;
60
+ while (current = walker.nextNode()) {
61
+ text += current.nodeValue;
62
+ }
63
+ return text;
64
+ };
47
65
  this.handleRef = el => {
48
66
  this.ref = el;
67
+ if (el) {
68
+ var _this$context$setBody, _this$context;
69
+ const txt = this.getTextExcludingButtons(el);
70
+ (_this$context$setBody = (_this$context = this.context).setBodyScrollAriaLabel) === null || _this$context$setBody === void 0 ? void 0 : _this$context$setBody.call(_this$context, txt);
71
+ }
49
72
  };
50
73
  this.makeStyleProps = () => {
51
74
  return {
@@ -85,6 +108,6 @@ let ModalHeader = (_dec = withStyle(generateStyle, generateComponentTheme), _dec
85
108
  }, _ModalHeader.displayName = "ModalHeader", _ModalHeader.componentId = 'Modal.Header', _ModalHeader.propTypes = propTypes, _ModalHeader.allowedProps = allowedProps, _ModalHeader.defaultProps = {
86
109
  variant: 'default',
87
110
  spacing: 'default'
88
- }, _ModalHeader)) || _class) || _class);
111
+ }, _ModalHeader.contextType = ModalContext, _ModalHeader)) || _class) || _class);
89
112
  export default ModalHeader;
90
113
  export { ModalHeader };
package/es/Modal/index.js CHANGED
@@ -40,13 +40,15 @@ import { withStyle } from '@instructure/emotion';
40
40
  import generateStyle from './styles';
41
41
  import generateComponentTheme from './theme';
42
42
  import { propTypes, allowedProps } from './props';
43
- import { jsx as _jsx } from "@emotion/react/jsx-runtime";
43
+ import ModalContext from './ModalContext';
44
+
44
45
  /**
45
46
  ---
46
47
  category: components
47
48
  tags: overlay, portal, dialog
48
49
  ---
49
50
  **/
51
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
50
52
  let Modal = (_dec = withStyle(generateStyle, generateComponentTheme), _dec2 = testable(), _dec(_class = _dec2(_class = (_Modal = class Modal extends Component {
51
53
  constructor(props) {
52
54
  var _props$open;
@@ -79,7 +81,8 @@ let Modal = (_dec = withStyle(generateStyle, generateComponentTheme), _dec2 = te
79
81
  this.state = {
80
82
  transitioning: false,
81
83
  open: (_props$open = props.open) !== null && _props$open !== void 0 ? _props$open : false,
82
- windowHeight: 99999
84
+ windowHeight: 99999,
85
+ bodyScrollAriaLabel: void 0
83
86
  };
84
87
  }
85
88
  componentDidMount() {
@@ -228,10 +231,18 @@ let Modal = (_dec = withStyle(generateStyle, generateComponentTheme), _dec2 = te
228
231
  onExit: onExit,
229
232
  onExiting: onExiting,
230
233
  onExited: createChainedFunction(this.handleTransitionComplete, onExited, onClose),
231
- children: constrain === 'parent' ? _jsx("span", {
232
- css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.constrainContext,
233
- children: this.renderDialog(passthroughProps)
234
- }) : this.renderDialog(passthroughProps)
234
+ children: _jsx(ModalContext.Provider, {
235
+ value: {
236
+ bodyScrollAriaLabel: this.state.bodyScrollAriaLabel,
237
+ setBodyScrollAriaLabel: txt => this.setState({
238
+ bodyScrollAriaLabel: txt
239
+ })
240
+ },
241
+ children: constrain === 'parent' ? _jsx("span", {
242
+ css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.constrainContext,
243
+ children: this.renderDialog(passthroughProps)
244
+ }) : this.renderDialog(passthroughProps)
245
+ })
235
246
  })
236
247
  });
237
248
  }
@@ -15,6 +15,7 @@ var _emotion = require("@instructure/emotion");
15
15
  var _styles = _interopRequireDefault(require("./styles"));
16
16
  var _theme = _interopRequireDefault(require("./theme"));
17
17
  var _props = require("./props");
18
+ var _ModalContext = _interopRequireDefault(require("../ModalContext"));
18
19
  var _jsxRuntime = require("@emotion/react/jsx-runtime");
19
20
  const _excluded = ["as", "elementRef", "overflow", "variant", "padding", "children"];
20
21
  var _dec, _dec2, _class, _ModalBody;
@@ -89,7 +90,7 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyle)(_styles.defa
89
90
  return void 0;
90
91
  }
91
92
  render() {
92
- var _this$props$styles, _finalRef$scrollHeigh, _finalRef$getBounding, _finalRef$getBounding2;
93
+ var _finalRef$scrollHeigh, _finalRef$getBounding, _finalRef$getBounding2;
93
94
  const _this$props3 = this.props,
94
95
  as = _this$props3.as,
95
96
  elementRef = _this$props3.elementRef,
@@ -104,22 +105,28 @@ let ModalBody = exports.ModalBody = (_dec = (0, _emotion.withStyle)(_styles.defa
104
105
  // TODO rethink, the 'as' prop, likely its not a good idea to allow React
105
106
  // components. See INSTUI-4674
106
107
  const finalRef = this.getFinalRef(this.ref);
107
- return (0, _jsxRuntime.jsx)(_View.View, {
108
- ...passthroughProps,
109
- display: "block",
110
- width: isFit ? '100%' : void 0,
111
- height: isFit ? '100%' : void 0,
112
- elementRef: this.handleRef,
113
- as: as,
114
- css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.modalBody,
115
- padding: padding
116
- // check if there is a scrollbar, if so, the element has to be tabbable to be able to scroll with keyboard only
117
- // epsilon tolerance is used to avoid false positives, this is generally safer than Math rounding techniques
118
- ,
119
- ...(finalRef && Math.abs(((_finalRef$scrollHeigh = finalRef.scrollHeight) !== null && _finalRef$scrollHeigh !== void 0 ? _finalRef$scrollHeigh : 0) - ((_finalRef$getBounding = (_finalRef$getBounding2 = finalRef.getBoundingClientRect()) === null || _finalRef$getBounding2 === void 0 ? void 0 : _finalRef$getBounding2.height) !== null && _finalRef$getBounding !== void 0 ? _finalRef$getBounding : 0)) > 0.05 ? {
120
- tabIndex: 0
121
- } : {}),
122
- children: children
108
+ const hasScrollbar = finalRef && Math.abs(((_finalRef$scrollHeigh = finalRef.scrollHeight) !== null && _finalRef$scrollHeigh !== void 0 ? _finalRef$scrollHeigh : 0) - ((_finalRef$getBounding = (_finalRef$getBounding2 = finalRef.getBoundingClientRect()) === null || _finalRef$getBounding2 === void 0 ? void 0 : _finalRef$getBounding2.height) !== null && _finalRef$getBounding !== void 0 ? _finalRef$getBounding : 0)) > 1;
109
+ return (0, _jsxRuntime.jsx)(_ModalContext.default.Consumer, {
110
+ children: value => {
111
+ var _this$props$styles;
112
+ return (0, _jsxRuntime.jsx)(_View.View, {
113
+ ...passthroughProps,
114
+ display: "block",
115
+ width: isFit ? '100%' : void 0,
116
+ height: isFit ? '100%' : void 0,
117
+ elementRef: this.handleRef,
118
+ as: as,
119
+ css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.modalBody,
120
+ padding: padding
121
+ // check if there is a scrollbar, if so, the element has to be tabbable
122
+ ,
123
+ ...(hasScrollbar ? {
124
+ tabIndex: 0,
125
+ 'aria-label': value.bodyScrollAriaLabel
126
+ } : {}),
127
+ children: children
128
+ });
129
+ }
123
130
  });
124
131
  }
125
132
  }, _ModalBody.displayName = "ModalBody", _ModalBody.componentId = 'Modal.Body', _ModalBody.propTypes = _props.propTypes, _ModalBody.allowedProps = _props.allowedProps, _ModalBody.defaultProps = {
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = exports.ModalContext = void 0;
7
+ var _react = require("react");
8
+ /*
9
+ * The MIT License (MIT)
10
+ *
11
+ * Copyright (c) 2015 - present Instructure, Inc.
12
+ *
13
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ * of this software and associated documentation files (the "Software"), to deal
15
+ * in the Software without restriction, including without limitation the rights
16
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ * copies of the Software, and to permit persons to whom the Software is
18
+ * furnished to do so, subject to the following conditions:
19
+ *
20
+ * The above copyright notice and this permission notice shall be included in all
21
+ * copies or substantial portions of the Software.
22
+ *
23
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ * SOFTWARE.
30
+ */
31
+
32
+ /**
33
+ * React context created by the `Modal` component
34
+ * @private
35
+ */
36
+ const ModalContext = exports.ModalContext = /*#__PURE__*/(0, _react.createContext)({
37
+ bodyScrollAriaLabel: void 0
38
+ });
39
+ var _default = exports.default = ModalContext;
@@ -15,6 +15,7 @@ var _CloseButton = require("@instructure/ui-buttons/lib/CloseButton");
15
15
  var _styles = _interopRequireDefault(require("./styles"));
16
16
  var _theme = _interopRequireDefault(require("./theme"));
17
17
  var _props = require("./props");
18
+ var _ModalContext = _interopRequireDefault(require("../ModalContext"));
18
19
  var _jsxRuntime = require("@emotion/react/jsx-runtime");
19
20
  const _excluded = ["children"];
20
21
  var _dec, _dec2, _class, _ModalHeader;
@@ -51,8 +52,30 @@ let ModalHeader = exports.ModalHeader = (_dec = (0, _emotion.withStyle)(_styles.
51
52
  constructor(...args) {
52
53
  super(...args);
53
54
  this.ref = null;
55
+ /**
56
+ * Gets all text in a DOM subtree, text under <button> nodes is excluded
57
+ */
58
+ this.getTextExcludingButtons = root => {
59
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
60
+ acceptNode(node) {
61
+ var _node$parentElement;
62
+ return (_node$parentElement = node.parentElement) !== null && _node$parentElement !== void 0 && _node$parentElement.closest('button') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
63
+ }
64
+ });
65
+ let text = '';
66
+ let current;
67
+ while (current = walker.nextNode()) {
68
+ text += current.nodeValue;
69
+ }
70
+ return text;
71
+ };
54
72
  this.handleRef = el => {
55
73
  this.ref = el;
74
+ if (el) {
75
+ var _this$context$setBody, _this$context;
76
+ const txt = this.getTextExcludingButtons(el);
77
+ (_this$context$setBody = (_this$context = this.context).setBodyScrollAriaLabel) === null || _this$context$setBody === void 0 ? void 0 : _this$context$setBody.call(_this$context, txt);
78
+ }
56
79
  };
57
80
  this.makeStyleProps = () => {
58
81
  return {
@@ -92,5 +115,5 @@ let ModalHeader = exports.ModalHeader = (_dec = (0, _emotion.withStyle)(_styles.
92
115
  }, _ModalHeader.displayName = "ModalHeader", _ModalHeader.componentId = 'Modal.Header', _ModalHeader.propTypes = _props.propTypes, _ModalHeader.allowedProps = _props.allowedProps, _ModalHeader.defaultProps = {
93
116
  variant: 'default',
94
117
  spacing: 'default'
95
- }, _ModalHeader)) || _class) || _class);
118
+ }, _ModalHeader.contextType = _ModalContext.default, _ModalHeader)) || _class) || _class);
96
119
  var _default = exports.default = ModalHeader;
@@ -41,6 +41,7 @@ var _emotion = require("@instructure/emotion");
41
41
  var _styles = _interopRequireDefault(require("./styles"));
42
42
  var _theme = _interopRequireDefault(require("./theme"));
43
43
  var _props = require("./props");
44
+ var _ModalContext = _interopRequireDefault(require("./ModalContext"));
44
45
  var _jsxRuntime = require("@emotion/react/jsx-runtime");
45
46
  const _excluded = ["open", "onOpen", "onClose", "mountNode", "insertAt", "transition", "onEnter", "onEntering", "onEntered", "onExit", "onExiting", "onExited", "constrain", "overflow"];
46
47
  var _dec, _dec2, _class, _Modal;
@@ -105,7 +106,8 @@ let Modal = exports.Modal = (_dec = (0, _emotion.withStyle)(_styles.default, _th
105
106
  this.state = {
106
107
  transitioning: false,
107
108
  open: (_props$open = props.open) !== null && _props$open !== void 0 ? _props$open : false,
108
- windowHeight: 99999
109
+ windowHeight: 99999,
110
+ bodyScrollAriaLabel: void 0
109
111
  };
110
112
  }
111
113
  componentDidMount() {
@@ -254,10 +256,18 @@ let Modal = exports.Modal = (_dec = (0, _emotion.withStyle)(_styles.default, _th
254
256
  onExit: onExit,
255
257
  onExiting: onExiting,
256
258
  onExited: (0, _createChainedFunction.createChainedFunction)(this.handleTransitionComplete, onExited, onClose),
257
- children: constrain === 'parent' ? (0, _jsxRuntime.jsx)("span", {
258
- css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.constrainContext,
259
- children: this.renderDialog(passthroughProps)
260
- }) : this.renderDialog(passthroughProps)
259
+ children: (0, _jsxRuntime.jsx)(_ModalContext.default.Provider, {
260
+ value: {
261
+ bodyScrollAriaLabel: this.state.bodyScrollAriaLabel,
262
+ setBodyScrollAriaLabel: txt => this.setState({
263
+ bodyScrollAriaLabel: txt
264
+ })
265
+ },
266
+ children: constrain === 'parent' ? (0, _jsxRuntime.jsx)("span", {
267
+ css: (_this$props$styles = this.props.styles) === null || _this$props$styles === void 0 ? void 0 : _this$props$styles.constrainContext,
268
+ children: this.renderDialog(passthroughProps)
269
+ }) : this.renderDialog(passthroughProps)
270
+ })
261
271
  })
262
272
  });
263
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instructure/ui-modal",
3
- "version": "10.29.0",
3
+ "version": "10.30.0",
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",
@@ -24,30 +24,30 @@
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
26
  "@babel/runtime": "^7.27.6",
27
- "@instructure/console": "10.29.0",
28
- "@instructure/emotion": "10.29.0",
29
- "@instructure/shared-types": "10.29.0",
30
- "@instructure/ui-buttons": "10.29.0",
31
- "@instructure/ui-dialog": "10.29.0",
32
- "@instructure/ui-dom-utils": "10.29.0",
33
- "@instructure/ui-motion": "10.29.0",
34
- "@instructure/ui-overlays": "10.29.0",
35
- "@instructure/ui-portal": "10.29.0",
36
- "@instructure/ui-prop-types": "10.29.0",
37
- "@instructure/ui-react-utils": "10.29.0",
38
- "@instructure/ui-testable": "10.29.0",
39
- "@instructure/ui-utils": "10.29.0",
40
- "@instructure/ui-view": "10.29.0",
27
+ "@instructure/console": "10.30.0",
28
+ "@instructure/emotion": "10.30.0",
29
+ "@instructure/shared-types": "10.30.0",
30
+ "@instructure/ui-buttons": "10.30.0",
31
+ "@instructure/ui-dialog": "10.30.0",
32
+ "@instructure/ui-dom-utils": "10.30.0",
33
+ "@instructure/ui-motion": "10.30.0",
34
+ "@instructure/ui-overlays": "10.30.0",
35
+ "@instructure/ui-portal": "10.30.0",
36
+ "@instructure/ui-prop-types": "10.30.0",
37
+ "@instructure/ui-react-utils": "10.30.0",
38
+ "@instructure/ui-testable": "10.30.0",
39
+ "@instructure/ui-utils": "10.30.0",
40
+ "@instructure/ui-view": "10.30.0",
41
41
  "prop-types": "^15.8.1"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "react": ">=16.14 <=18"
45
45
  },
46
46
  "devDependencies": {
47
- "@instructure/ui-babel-preset": "10.29.0",
48
- "@instructure/ui-color-utils": "10.29.0",
49
- "@instructure/ui-position": "10.29.0",
50
- "@instructure/ui-themes": "10.29.0",
47
+ "@instructure/ui-babel-preset": "10.30.0",
48
+ "@instructure/ui-color-utils": "10.30.0",
49
+ "@instructure/ui-position": "10.30.0",
50
+ "@instructure/ui-themes": "10.30.0",
51
51
  "@testing-library/jest-dom": "^6.6.3",
52
52
  "@testing-library/react": "^16.0.1",
53
53
  "@testing-library/user-event": "^14.6.1",
@@ -36,6 +36,7 @@ import generateComponentTheme from './theme'
36
36
  import { propTypes, allowedProps } from './props'
37
37
  import type { ModalBodyProps } from './props'
38
38
  import { UIElement } from '@instructure/shared-types'
39
+ import ModalContext from '../ModalContext'
39
40
 
40
41
  /**
41
42
  ---
@@ -120,29 +121,36 @@ class ModalBody extends Component<ModalBodyProps> {
120
121
  // TODO rethink, the 'as' prop, likely its not a good idea to allow React
121
122
  // components. See INSTUI-4674
122
123
  const finalRef = this.getFinalRef(this.ref)
123
-
124
+ const hasScrollbar =
125
+ finalRef &&
126
+ Math.abs(
127
+ (finalRef.scrollHeight ?? 0) -
128
+ (finalRef.getBoundingClientRect()?.height ?? 0)
129
+ ) > 1
124
130
  return (
125
- <View
126
- {...passthroughProps}
127
- display="block"
128
- width={isFit ? '100%' : undefined}
129
- height={isFit ? '100%' : undefined}
130
- elementRef={this.handleRef}
131
- as={as}
132
- css={this.props.styles?.modalBody}
133
- padding={padding}
134
- // check if there is a scrollbar, if so, the element has to be tabbable to be able to scroll with keyboard only
135
- // epsilon tolerance is used to avoid false positives, this is generally safer than Math rounding techniques
136
- {...(finalRef &&
137
- Math.abs(
138
- (finalRef.scrollHeight ?? 0) -
139
- (finalRef.getBoundingClientRect()?.height ?? 0)
140
- ) > 0.05
141
- ? { tabIndex: 0 }
142
- : {})}
143
- >
144
- {children}
145
- </View>
131
+ <ModalContext.Consumer>
132
+ {(value) => (
133
+ <View
134
+ {...passthroughProps}
135
+ display="block"
136
+ width={isFit ? '100%' : undefined}
137
+ height={isFit ? '100%' : undefined}
138
+ elementRef={this.handleRef}
139
+ as={as}
140
+ css={this.props.styles?.modalBody}
141
+ padding={padding}
142
+ // check if there is a scrollbar, if so, the element has to be tabbable
143
+ {...(hasScrollbar
144
+ ? {
145
+ tabIndex: 0,
146
+ 'aria-label': value.bodyScrollAriaLabel
147
+ }
148
+ : {})}
149
+ >
150
+ {children}
151
+ </View>
152
+ )}
153
+ </ModalContext.Consumer>
146
154
  )
147
155
  }
148
156
  }
@@ -0,0 +1,46 @@
1
+ /*
2
+ * The MIT License (MIT)
3
+ *
4
+ * Copyright (c) 2015 - present Instructure, Inc.
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ import { createContext } from 'react'
26
+
27
+ type ModalContextType = {
28
+ /**
29
+ * Needed for setting the correct aria-label in the modal body when its scrollable.
30
+ * This is a value read from the Modal's Header.
31
+ */
32
+ bodyScrollAriaLabel?: string
33
+ setBodyScrollAriaLabel?: (txt: string) => void
34
+ }
35
+
36
+ /**
37
+ * React context created by the `Modal` component
38
+ * @private
39
+ */
40
+ const ModalContext = createContext<ModalContextType>({
41
+ bodyScrollAriaLabel: undefined
42
+ })
43
+
44
+ export default ModalContext
45
+ export { ModalContext }
46
+ export type { ModalContextType }
@@ -40,6 +40,7 @@ import generateComponentTheme from './theme'
40
40
 
41
41
  import { propTypes, allowedProps } from './props'
42
42
  import type { ModalHeaderProps, ModalHeaderStyleProps } from './props'
43
+ import ModalContext from '../ModalContext'
43
44
 
44
45
  type CloseButtonChild = ComponentElement<CloseButtonProps, CloseButton>
45
46
 
@@ -62,9 +63,34 @@ class ModalHeader extends Component<ModalHeaderProps> {
62
63
  }
63
64
 
64
65
  ref: HTMLDivElement | null = null
66
+ declare context: React.ContextType<typeof ModalContext>
67
+ static contextType = ModalContext
68
+
69
+ /**
70
+ * Gets all text in a DOM subtree, text under <button> nodes is excluded
71
+ */
72
+ getTextExcludingButtons = (root: Node) => {
73
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
74
+ acceptNode(node) {
75
+ return node.parentElement?.closest('button')
76
+ ? NodeFilter.FILTER_REJECT
77
+ : NodeFilter.FILTER_ACCEPT
78
+ }
79
+ })
80
+ let text = ''
81
+ let current
82
+ while ((current = walker.nextNode())) {
83
+ text += current.nodeValue
84
+ }
85
+ return text
86
+ }
65
87
 
66
88
  handleRef = (el: HTMLDivElement | null) => {
67
89
  this.ref = el
90
+ if (el) {
91
+ const txt = this.getTextExcludingButtons(el)
92
+ this.context.setBodyScrollAriaLabel?.(txt)
93
+ }
68
94
  }
69
95
 
70
96
  componentDidMount() {