UNPKG

12.3 kBJavaScriptView Raw
1import React from 'react';
2import PropTypes from 'prop-types';
3import classNames from 'classnames';
4import Portal from './Portal';
5import Fade from './Fade';
6import {
7 TransitionTimeouts,
8 conditionallyUpdateScrollbar,
9 focusableElements,
10 getOriginalBodyPadding,
11 getTarget,
12 keyCodes,
13 mapToCssModules,
14 omit,
15 setScrollbarWidth,
16 targetPropType,
17} from './utils';
18
19function noop() {}
20
21const FadePropTypes = PropTypes.shape(Fade.propTypes);
22
23const 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
57const propsToOmit = Object.keys(propTypes);
58
59const 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, // uses standard fade transition
77 },
78 unmountOnClose: true,
79 returnFocusAfterClose: true,
80 container: 'body',
81 trapFocus: false,
82};
83
84class 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 // traps focus inside the Offcanvas, even if the browser address bar is focused
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 // now Offcanvas Dialog is rendered and we can refer this._element and this._dialog
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 // not mouseUp because scrollbar fires it, shouldn't close when user scrolls
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; // last opened offcanvas
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 // so all methods get called before it is unmounted
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 // element is not attached
275 return;
276 }
277
278 if (this._dialog.current === ev.target) {
279 // initial focus when the Offcanvas is opened
280 return;
281 }
282
283 if (this.offcanvasIndex < Offcanvas.openCount - 1) {
284 // last opened offcanvas
285 return;
286 }
287
288 const children = this.getFocusableChildren();
289
290 for (let i = 0; i < children.length; i += 1) {
291 // focus is already inside the Offcanvas
292 if (children[i] === ev.target) return;
293 }
294
295 if (children.length > 0) {
296 // otherwise focus the first focusable element in the Offcanvas
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
469Offcanvas.propTypes = propTypes;
470Offcanvas.defaultProps = defaultProps;
471Offcanvas.openCount = 0;
472
473export default Offcanvas;