1 | import React from 'react';
|
2 | import PropTypes from 'prop-types';
|
3 | import classNames from 'classnames';
|
4 | import Portal from './Portal';
|
5 | import Fade from './Fade';
|
6 | import {
|
7 | TransitionTimeouts,
|
8 | conditionallyUpdateScrollbar,
|
9 | focusableElements,
|
10 | getOriginalBodyPadding,
|
11 | getTarget,
|
12 | keyCodes,
|
13 | mapToCssModules,
|
14 | omit,
|
15 | setScrollbarWidth,
|
16 | targetPropType,
|
17 | } from './utils';
|
18 |
|
19 | function noop() {}
|
20 |
|
21 | const FadePropTypes = PropTypes.shape(Fade.propTypes);
|
22 |
|
23 | const propTypes = {
|
24 | autoFocus: PropTypes.bool,
|
25 | backdrop: PropTypes.bool,
|
26 | backdropClassName: PropTypes.string,
|
27 | backdropTransition: FadePropTypes,
|
28 | children: PropTypes.node,
|
29 | className: PropTypes.string,
|
30 | container: targetPropType,
|
31 | cssModule: PropTypes.object,
|
32 | direction: PropTypes.oneOf(['start', 'end', 'bottom', 'top']),
|
33 | fade: PropTypes.bool,
|
34 | innerRef: PropTypes.oneOfType([
|
35 | PropTypes.object,
|
36 | PropTypes.string,
|
37 | PropTypes.func,
|
38 | ]),
|
39 | isOpen: PropTypes.bool,
|
40 | keyboard: PropTypes.bool,
|
41 | labelledBy: PropTypes.string,
|
42 | offcanvasTransition: FadePropTypes,
|
43 | onClosed: PropTypes.func,
|
44 | onEnter: PropTypes.func,
|
45 | onExit: PropTypes.func,
|
46 | style: PropTypes.object,
|
47 | onOpened: PropTypes.func,
|
48 | returnFocusAfterClose: PropTypes.bool,
|
49 | role: PropTypes.string,
|
50 | scrollable: PropTypes.bool,
|
51 | toggle: PropTypes.func,
|
52 | trapFocus: PropTypes.bool,
|
53 | unmountOnClose: PropTypes.bool,
|
54 | zIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
55 | };
|
56 |
|
57 | const propsToOmit = Object.keys(propTypes);
|
58 |
|
59 | const defaultProps = {
|
60 | isOpen: false,
|
61 | autoFocus: true,
|
62 | direction: 'start',
|
63 | scrollable: false,
|
64 | role: 'dialog',
|
65 | backdrop: true,
|
66 | keyboard: true,
|
67 | zIndex: 1050,
|
68 | fade: true,
|
69 | onOpened: noop,
|
70 | onClosed: noop,
|
71 | offcanvasTransition: {
|
72 | timeout: TransitionTimeouts.Offcanvas,
|
73 | },
|
74 | backdropTransition: {
|
75 | mountOnEnter: true,
|
76 | timeout: TransitionTimeouts.Fade,
|
77 | },
|
78 | unmountOnClose: true,
|
79 | returnFocusAfterClose: true,
|
80 | container: 'body',
|
81 | trapFocus: false,
|
82 | };
|
83 |
|
84 | class Offcanvas extends React.Component {
|
85 | constructor(props) {
|
86 | super(props);
|
87 |
|
88 | this._element = null;
|
89 | this._originalBodyPadding = null;
|
90 | this.getFocusableChildren = this.getFocusableChildren.bind(this);
|
91 | this.handleBackdropClick = this.handleBackdropClick.bind(this);
|
92 | this.handleBackdropMouseDown = this.handleBackdropMouseDown.bind(this);
|
93 | this.handleEscape = this.handleEscape.bind(this);
|
94 | this.handleTab = this.handleTab.bind(this);
|
95 | this.onOpened = this.onOpened.bind(this);
|
96 | this.onClosed = this.onClosed.bind(this);
|
97 | this.manageFocusAfterClose = this.manageFocusAfterClose.bind(this);
|
98 | this.clearBackdropAnimationTimeout =
|
99 | this.clearBackdropAnimationTimeout.bind(this);
|
100 | this.trapFocus = this.trapFocus.bind(this);
|
101 | this._backdrop = React.createRef();
|
102 | this._dialog = React.createRef();
|
103 |
|
104 | this.state = {
|
105 | isOpen: false,
|
106 | };
|
107 | }
|
108 |
|
109 | componentDidMount() {
|
110 | const { isOpen, autoFocus, onEnter } = this.props;
|
111 |
|
112 | if (isOpen) {
|
113 | this.init();
|
114 | this.setState({ isOpen: true });
|
115 | if (autoFocus) {
|
116 | this.setFocus();
|
117 | }
|
118 | }
|
119 |
|
120 | if (onEnter) {
|
121 | onEnter();
|
122 | }
|
123 |
|
124 |
|
125 | document.addEventListener('focus', this.trapFocus, true);
|
126 |
|
127 | this._isMounted = true;
|
128 | }
|
129 |
|
130 | componentDidUpdate(prevProps, prevState) {
|
131 | if (this.props.isOpen && !prevProps.isOpen) {
|
132 | this.init();
|
133 | this.setState({ isOpen: true });
|
134 |
|
135 | return;
|
136 | }
|
137 |
|
138 |
|
139 | if (this.props.autoFocus && this.state.isOpen && !prevState.isOpen) {
|
140 | this.setFocus();
|
141 | }
|
142 |
|
143 | if (this._element && prevProps.zIndex !== this.props.zIndex) {
|
144 | this._element.style.zIndex = this.props.zIndex;
|
145 | }
|
146 | }
|
147 |
|
148 | componentWillUnmount() {
|
149 | this.clearBackdropAnimationTimeout();
|
150 |
|
151 | if (this.props.onExit) {
|
152 | this.props.onExit();
|
153 | }
|
154 |
|
155 | if (this._element) {
|
156 | this.destroy();
|
157 | if (this.props.isOpen || this.state.isOpen) {
|
158 | this.close();
|
159 | }
|
160 | }
|
161 |
|
162 | document.removeEventListener('focus', this.trapFocus, true);
|
163 | this._isMounted = false;
|
164 | }
|
165 |
|
166 |
|
167 | handleBackdropClick(e) {
|
168 | if (e.target === this._mouseDownElement) {
|
169 | e.stopPropagation();
|
170 | const backdrop = this._backdrop.current;
|
171 |
|
172 | if (!this.props.isOpen || this.props.backdrop !== true) return;
|
173 |
|
174 | if (backdrop && e.target === backdrop && this.props.toggle) {
|
175 | this.props.toggle(e);
|
176 | }
|
177 | }
|
178 | }
|
179 |
|
180 | handleTab(e) {
|
181 | if (e.which !== 9) return;
|
182 | if (this.offcanvasIndex < Offcanvas.openCount - 1) return;
|
183 |
|
184 | const focusableChildren = this.getFocusableChildren();
|
185 | const totalFocusable = focusableChildren.length;
|
186 | if (totalFocusable === 0) return;
|
187 | const currentFocus = this.getFocusedChild();
|
188 |
|
189 | let focusedIndex = 0;
|
190 |
|
191 | for (let i = 0; i < totalFocusable; i += 1) {
|
192 | if (focusableChildren[i] === currentFocus) {
|
193 | focusedIndex = i;
|
194 | break;
|
195 | }
|
196 | }
|
197 |
|
198 | if (e.shiftKey && focusedIndex === 0) {
|
199 | e.preventDefault();
|
200 | focusableChildren[totalFocusable - 1].focus();
|
201 | } else if (!e.shiftKey && focusedIndex === totalFocusable - 1) {
|
202 | e.preventDefault();
|
203 | focusableChildren[0].focus();
|
204 | }
|
205 | }
|
206 |
|
207 | handleBackdropMouseDown(e) {
|
208 | this._mouseDownElement = e.target;
|
209 | }
|
210 |
|
211 | handleEscape(e) {
|
212 | if (this.props.isOpen && e.keyCode === keyCodes.esc && this.props.toggle) {
|
213 | if (this.props.keyboard) {
|
214 | e.preventDefault();
|
215 | e.stopPropagation();
|
216 |
|
217 | this.props.toggle(e);
|
218 | }
|
219 | }
|
220 | }
|
221 |
|
222 | onOpened(node, isAppearing) {
|
223 | this.props.onOpened();
|
224 | (this.props.offcanvasTransition.onEntered || noop)(node, isAppearing);
|
225 | }
|
226 |
|
227 | onClosed(node) {
|
228 | const { unmountOnClose } = this.props;
|
229 |
|
230 | this.props.onClosed();
|
231 | (this.props.offcanvasTransition.onExited || noop)(node);
|
232 |
|
233 | if (unmountOnClose) {
|
234 | this.destroy();
|
235 | }
|
236 | this.close();
|
237 |
|
238 | if (this._isMounted) {
|
239 | this.setState({ isOpen: false });
|
240 | }
|
241 | }
|
242 |
|
243 | setFocus() {
|
244 | if (
|
245 | this._dialog.current &&
|
246 | typeof this._dialog.current.focus === 'function'
|
247 | ) {
|
248 | this._dialog.current.focus();
|
249 | }
|
250 | }
|
251 |
|
252 | getFocusableChildren() {
|
253 | return this._element.querySelectorAll(focusableElements.join(', '));
|
254 | }
|
255 |
|
256 | getFocusedChild() {
|
257 | let currentFocus;
|
258 | const focusableChildren = this.getFocusableChildren();
|
259 |
|
260 | try {
|
261 | currentFocus = document.activeElement;
|
262 | } catch (err) {
|
263 | currentFocus = focusableChildren[0];
|
264 | }
|
265 | return currentFocus;
|
266 | }
|
267 |
|
268 | trapFocus(ev) {
|
269 | if (!this.props.trapFocus) {
|
270 | return;
|
271 | }
|
272 |
|
273 | if (!this._element) {
|
274 |
|
275 | return;
|
276 | }
|
277 |
|
278 | if (this._dialog.current === ev.target) {
|
279 |
|
280 | return;
|
281 | }
|
282 |
|
283 | if (this.offcanvasIndex < Offcanvas.openCount - 1) {
|
284 |
|
285 | return;
|
286 | }
|
287 |
|
288 | const children = this.getFocusableChildren();
|
289 |
|
290 | for (let i = 0; i < children.length; i += 1) {
|
291 |
|
292 | if (children[i] === ev.target) return;
|
293 | }
|
294 |
|
295 | if (children.length > 0) {
|
296 |
|
297 | ev.preventDefault();
|
298 | ev.stopPropagation();
|
299 | children[0].focus();
|
300 | }
|
301 | }
|
302 |
|
303 | init() {
|
304 | try {
|
305 | this._triggeringElement = document.activeElement;
|
306 | } catch (err) {
|
307 | this._triggeringElement = null;
|
308 | }
|
309 |
|
310 | if (!this._element) {
|
311 | this._element = document.createElement('div');
|
312 | this._element.setAttribute('tabindex', '-1');
|
313 | this._element.style.position = 'relative';
|
314 | this._element.style.zIndex = this.props.zIndex;
|
315 | this._mountContainer = getTarget(this.props.container);
|
316 | this._mountContainer.appendChild(this._element);
|
317 | }
|
318 |
|
319 | this._originalBodyPadding = getOriginalBodyPadding();
|
320 | conditionallyUpdateScrollbar();
|
321 |
|
322 | if (
|
323 | Offcanvas.openCount === 0 &&
|
324 | this.props.backdrop &&
|
325 | !this.props.scrollable
|
326 | ) {
|
327 | document.body.style.overflow = 'hidden';
|
328 | }
|
329 |
|
330 | this.offcanvasIndex = Offcanvas.openCount;
|
331 | Offcanvas.openCount += 1;
|
332 | }
|
333 |
|
334 | destroy() {
|
335 | if (this._element) {
|
336 | this._mountContainer.removeChild(this._element);
|
337 | this._element = null;
|
338 | }
|
339 |
|
340 | this.manageFocusAfterClose();
|
341 | }
|
342 |
|
343 | manageFocusAfterClose() {
|
344 | if (this._triggeringElement) {
|
345 | const { returnFocusAfterClose } = this.props;
|
346 | if (this._triggeringElement.focus && returnFocusAfterClose)
|
347 | this._triggeringElement.focus();
|
348 | this._triggeringElement = null;
|
349 | }
|
350 | }
|
351 |
|
352 | close() {
|
353 | this.manageFocusAfterClose();
|
354 | Offcanvas.openCount = Math.max(0, Offcanvas.openCount - 1);
|
355 |
|
356 | document.body.style.overflow = null;
|
357 | setScrollbarWidth(this._originalBodyPadding);
|
358 | }
|
359 |
|
360 | clearBackdropAnimationTimeout() {
|
361 | if (this._backdropAnimationTimeout) {
|
362 | clearTimeout(this._backdropAnimationTimeout);
|
363 | this._backdropAnimationTimeout = undefined;
|
364 | }
|
365 | }
|
366 |
|
367 | render() {
|
368 | const { direction, unmountOnClose } = this.props;
|
369 |
|
370 | if (!!this._element && (this.state.isOpen || !unmountOnClose)) {
|
371 | const isOffcanvasHidden =
|
372 | !!this._element && !this.state.isOpen && !unmountOnClose;
|
373 | this._element.style.display = isOffcanvasHidden ? 'none' : 'block';
|
374 |
|
375 | const {
|
376 | className,
|
377 | backdropClassName,
|
378 | cssModule,
|
379 | isOpen,
|
380 | backdrop,
|
381 | role,
|
382 | labelledBy,
|
383 | style,
|
384 | } = this.props;
|
385 |
|
386 | const offcanvasAttributes = {
|
387 | onKeyUp: this.handleEscape,
|
388 | onKeyDown: this.handleTab,
|
389 | 'aria-labelledby': labelledBy,
|
390 | role,
|
391 | tabIndex: '-1',
|
392 | };
|
393 |
|
394 | const hasTransition = this.props.fade;
|
395 | const offcanvasTransition = {
|
396 | ...Fade.defaultProps,
|
397 | ...this.props.offcanvasTransition,
|
398 | baseClass: hasTransition
|
399 | ? this.props.offcanvasTransition.baseClass
|
400 | : '',
|
401 | timeout: hasTransition ? this.props.offcanvasTransition.timeout : 0,
|
402 | };
|
403 | const backdropTransition = {
|
404 | ...Fade.defaultProps,
|
405 | ...this.props.backdropTransition,
|
406 | baseClass: hasTransition ? this.props.backdropTransition.baseClass : '',
|
407 | timeout: hasTransition ? this.props.backdropTransition.timeout : 0,
|
408 | };
|
409 |
|
410 | const Backdrop =
|
411 | backdrop &&
|
412 | (hasTransition ? (
|
413 | <Fade
|
414 | {...backdropTransition}
|
415 | in={isOpen && !!backdrop}
|
416 | innerRef={this._backdrop}
|
417 | cssModule={cssModule}
|
418 | className={mapToCssModules(
|
419 | classNames('offcanvas-backdrop', backdropClassName),
|
420 | cssModule,
|
421 | )}
|
422 | onClick={this.handleBackdropClick}
|
423 | onMouseDown={this.handleBackdropMouseDown}
|
424 | />
|
425 | ) : (
|
426 | <div
|
427 | className={mapToCssModules(
|
428 | classNames('offcanvas-backdrop', 'show', backdropClassName),
|
429 | cssModule,
|
430 | )}
|
431 | ref={this._backdrop}
|
432 | onClick={this.handleBackdropClick}
|
433 | onMouseDown={this.handleBackdropMouseDown}
|
434 | />
|
435 | ));
|
436 |
|
437 | const attributes = omit(this.props, propsToOmit);
|
438 |
|
439 | return (
|
440 | <Portal node={this._element}>
|
441 | <Fade
|
442 | {...attributes}
|
443 | {...offcanvasAttributes}
|
444 | {...offcanvasTransition}
|
445 | in={isOpen}
|
446 | onEntered={this.onOpened}
|
447 | onExited={this.onClosed}
|
448 | cssModule={cssModule}
|
449 | className={mapToCssModules(
|
450 | classNames('offcanvas', className, `offcanvas-${direction}`),
|
451 | cssModule,
|
452 | )}
|
453 | innerRef={this._dialog}
|
454 | style={{
|
455 | ...style,
|
456 | visibility: isOpen ? 'visible' : 'hidden',
|
457 | }}
|
458 | >
|
459 | {this.props.children}
|
460 | </Fade>
|
461 | {Backdrop}
|
462 | </Portal>
|
463 | );
|
464 | }
|
465 | return null;
|
466 | }
|
467 | }
|
468 |
|
469 | Offcanvas.propTypes = propTypes;
|
470 | Offcanvas.defaultProps = defaultProps;
|
471 | Offcanvas.openCount = 0;
|
472 |
|
473 | export default Offcanvas;
|