diff --git a/docs/lib/Components/OffcanvasPage.js b/docs/lib/Components/OffcanvasPage.js new file mode 100644 index 000000000..d5f9eeeae --- /dev/null +++ b/docs/lib/Components/OffcanvasPage.js @@ -0,0 +1,80 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ +import React from 'react'; +import { PrismCode } from 'react-prism'; +import PageTitle from '../UI/PageTitle'; +import SectionTitle from '../UI/SectionTitle'; + +import OffcanvasExample from '../examples/Offcanvas'; +const OffcanvasExampleSource = require('!!raw-loader!../examples/Offcanvas'); + +export default class OffcanvasPage extends React.Component { + render() { + return ( +
+ +
+ +
+
+          
+            {OffcanvasExampleSource}
+          
+        
+ Properties +
+          
+{`
+Offcanvas.propTypes = {
+  autoFocus: PropTypes.bool,
+  backdrop: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['static'])]),
+  backdropClassName: PropTypes.string,
+  backdropTransition: FadePropTypes,
+  children: PropTypes.node,
+  className: PropTypes.string,
+  container: targetPropType,
+  cssModule: PropTypes.object,
+  direction: PropTypes.oneOf(['start', 'end', 'bottom', 'left', 'right']),
+  fade: PropTypes.bool,
+  innerRef: PropTypes.oneOfType([PropTypes.object, PropTypes.string, PropTypes.func,]),
+  isOpen: PropTypes.bool,
+  keyboard: PropTypes.bool,
+  labelledBy: PropTypes.string,
+  offcanvasClassName: PropTypes.string,
+  offcanvasTransition: FadePropTypes,
+  onClosed: PropTypes.func,
+  onEnter: PropTypes.func,
+  onExit: PropTypes.func,
+  onOpened: PropTypes.func,
+  returnFocusAfterClose: PropTypes.bool,
+  role: PropTypes.string,
+  scrollable: PropTypes.bool,
+  toggle: PropTypes.func,
+  trapFocus: PropTypes.bool,
+  unmountOnClose: PropTypes.bool,
+  wrapClassName: PropTypes.string,
+  zIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string,])
+}
+
+OffcanvasBody.propTypes = {
+  tag: tagPropType,
+  className: PropTypes.string,
+  cssModule: PropTypes.object,
+}
+
+OffcanvasHeader.propTypes = {
+  children: PropTypes.node,
+  className: PropTypes.string,
+  close: PropTypes.object,
+  closeAriaLabel: PropTypes.string,
+  cssModule: PropTypes.object,
+  tag: tagPropType,
+  toggle: PropTypes.func,
+  wrapTag: tagPropType
+}
+`}
+          
+        
+
+ ); + } +} diff --git a/docs/lib/Components/index.js b/docs/lib/Components/index.js index 8280105bf..4421613ad 100644 --- a/docs/lib/Components/index.js +++ b/docs/lib/Components/index.js @@ -86,6 +86,10 @@ const items = [ name: 'Navs', to: '/components/navs/' }, + { + name: 'Offcanvas', + to: '/components/offcanvas/' + }, { name: 'Placeholder', to: '/components/placeholder/' diff --git a/docs/lib/examples/Offcanvas.js b/docs/lib/examples/Offcanvas.js new file mode 100644 index 000000000..2924bd3a6 --- /dev/null +++ b/docs/lib/examples/Offcanvas.js @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { Button, ButtonToolbar, Offcanvas, OffcanvasBody, OffcanvasHeader } from 'reactstrap'; + +const Example = () => { + const [direction, setDirection] = useState(); + const [open, setOpen] = useState(); + const toggle = () => setOpen(!open); + + return ( +
+ + + + + + + + + Offcanvas {direction} + + + This is the Offcanvas body. + + +
+ ); +}; + +export default Example; diff --git a/docs/lib/routes.js b/docs/lib/routes.js index 7fcd4734e..f43a8f080 100644 --- a/docs/lib/routes.js +++ b/docs/lib/routes.js @@ -5,6 +5,7 @@ import PremiumThemes from './PremiumThemes'; import LayoutPage from './Components/LayoutPage'; import NavsPage from './Components/NavsPage'; import NavbarPage from './Components/NavbarPage'; +import OffcanvasPage from './Components/OffcanvasPage'; import BreadcrumbsPage from './Components/BreadcrumbsPage'; import ButtonsPage from './Components/ButtonsPage'; import ButtonGroupPage from './Components/ButtonGroupPage'; @@ -63,6 +64,7 @@ const routes = ( + diff --git a/package.json b/package.json index 23c9b8e98..e0a61e0e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "reactstrap", "version": "9.0.0-0", - "description": "React Bootstrap 4 components", + "description": "React Bootstrap components", "main": "lib/index.js", "types": "es/index.d.ts", "jsnext:main": "es/index.js", diff --git a/src/Offcanvas.js b/src/Offcanvas.js new file mode 100644 index 000000000..6ad7abdf1 --- /dev/null +++ b/src/Offcanvas.js @@ -0,0 +1,444 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Portal from './Portal'; +import Fade from './Fade'; +import { + TransitionTimeouts, + conditionallyUpdateScrollbar, + focusableElements, + getOriginalBodyPadding, + getTarget, + keyCodes, + mapToCssModules, + omit, + setScrollbarWidth, + targetPropType, +} from './utils'; + +function noop() { } + +const FadePropTypes = PropTypes.shape(Fade.propTypes); + +const propTypes = { + autoFocus: PropTypes.bool, + backdrop: PropTypes.bool, + backdropClassName: PropTypes.string, + backdropTransition: FadePropTypes, + children: PropTypes.node, + className: PropTypes.string, + container: targetPropType, + cssModule: PropTypes.object, + direction: PropTypes.oneOf(['start', 'end', 'bottom', 'top', 'left', 'right']), + fade: PropTypes.bool, + innerRef: PropTypes.oneOfType([PropTypes.object, PropTypes.string, PropTypes.func,]), + isOpen: PropTypes.bool, + keyboard: PropTypes.bool, + labelledBy: PropTypes.string, + offcanvasTransition: FadePropTypes, + onClosed: PropTypes.func, + onEnter: PropTypes.func, + onExit: PropTypes.func, + onOpened: PropTypes.func, + returnFocusAfterClose: PropTypes.bool, + role: PropTypes.string, + scrollable: PropTypes.bool, + toggle: PropTypes.func, + trapFocus: PropTypes.bool, + unmountOnClose: PropTypes.bool, + zIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string,]) +}; + +const propsToOmit = Object.keys(propTypes); + +const defaultProps = { + isOpen: false, + autoFocus: true, + direction: 'start', + scrollable: false, + role: 'dialog', + backdrop: true, + keyboard: true, + zIndex: 1050, + fade: true, + onOpened: noop, + onClosed: noop, + offcanvasTransition: { + timeout: TransitionTimeouts.Offcanvas, + }, + backdropTransition: { + mountOnEnter: true, + timeout: TransitionTimeouts.Fade, // uses standard fade transition + }, + unmountOnClose: true, + returnFocusAfterClose: true, + container: 'body', + trapFocus: false +}; + +class Offcanvas extends React.Component { + constructor(props) { + super(props); + + this._element = null; + this._originalBodyPadding = null; + this.getFocusableChildren = this.getFocusableChildren.bind(this); + this.handleBackdropClick = this.handleBackdropClick.bind(this); + this.handleBackdropMouseDown = this.handleBackdropMouseDown.bind(this); + this.handleEscape = this.handleEscape.bind(this); + this.handleTab = this.handleTab.bind(this); + this.onOpened = this.onOpened.bind(this); + this.onClosed = this.onClosed.bind(this); + this.manageFocusAfterClose = this.manageFocusAfterClose.bind(this); + this.clearBackdropAnimationTimeout = this.clearBackdropAnimationTimeout.bind(this); + this.trapFocus = this.trapFocus.bind(this); + + this.state = { + isOpen: false + }; + } + + componentDidMount() { + const { isOpen, autoFocus, onEnter } = this.props; + + if (isOpen) { + this.init(); + this.setState({ isOpen: true }) + if (autoFocus) { + this.setFocus(); + } + } + + if (onEnter) { + onEnter(); + } + + // traps focus inside the Offcanvas, even if the browser address bar is focused + document.addEventListener('focus', this.trapFocus, true); + + this._isMounted = true; + } + + componentDidUpdate(prevProps, prevState) { + if (this.props.isOpen && !prevProps.isOpen) { + this.init(); + this.setState({ isOpen: true }); + + return; + } + + // now Offcanvas Dialog is rendered and we can refer this._element and this._dialog + if (this.props.autoFocus && this.state.isOpen && !prevState.isOpen) { + this.setFocus(); + } + + if (this._element && prevProps.zIndex !== this.props.zIndex) { + this._element.style.zIndex = this.props.zIndex; + } + } + + componentWillUnmount() { + this.clearBackdropAnimationTimeout(); + + if (this.props.onExit) { + this.props.onExit(); + } + + if (this._element) { + this.destroy(); + if (this.props.isOpen || this.state.isOpen) { + this.close(); + } + } + + document.removeEventListener('focus', this.trapFocus, true); + this._isMounted = false; + } + + trapFocus (ev) { + if (!this.props.trapFocus) { + return; + } + + if (!this._element) //element is not attached + return; + + if (this._dialog === ev.target) // initial focus when the Offcanvas is opened + return; + + if (this.offcanvasIndex < (Offcanvas.openCount - 1)) // last opened offcanvas + return; + + const children = this.getFocusableChildren(); + + for (let i = 0; i < children.length; i++) { // focus is already inside the Offcanvas + if (children[i] === ev.target) + return; + } + + if (children.length > 0) { // otherwise focus the first focusable element in the Offcanvas + ev.preventDefault(); + ev.stopPropagation(); + children[0].focus(); + } + } + + onOpened(node, isAppearing) { + this.props.onOpened(); + (this.props.offcanvasTransition.onEntered || noop)(node, isAppearing); + } + + onClosed(node) { + const { unmountOnClose } = this.props; + // so all methods get called before it is unmounted + this.props.onClosed(); + (this.props.offcanvasTransition.onExited || noop)(node); + + if (unmountOnClose) { + this.destroy(); + } + this.close(); + + if (this._isMounted) { + this.setState({ isOpen: false }); + } + } + + setFocus() { + if (this._dialog && typeof this._dialog.focus === 'function') { + this._dialog.focus(); + } + } + + getFocusableChildren() { + return this._element.querySelectorAll(focusableElements.join(', ')); + } + + getFocusedChild() { + let currentFocus; + const focusableChildren = this.getFocusableChildren(); + + try { + currentFocus = document.activeElement; + } catch (err) { + currentFocus = focusableChildren[0]; + } + return currentFocus; + } + + // not mouseUp because scrollbar fires it, shouldn't close when user scrolls + handleBackdropClick(e) { + if (e.target === this._mouseDownElement) { + e.stopPropagation(); + const backdrop = this._backdrop; + + if (!this.props.isOpen || this.props.backdrop !== true) return; + + if (backdrop && e.target === backdrop && this.props.toggle) { + this.props.toggle(e); + } + } + } + + handleTab(e) { + if (e.which !== 9) return; + if (this.offcanvasIndex < (Offcanvas.openCount - 1)) return; // last opened offcanvas + + const focusableChildren = this.getFocusableChildren(); + const totalFocusable = focusableChildren.length; + if (totalFocusable === 0) return; + const currentFocus = this.getFocusedChild(); + + let focusedIndex = 0; + + for (let i = 0; i < totalFocusable; i += 1) { + if (focusableChildren[i] === currentFocus) { + focusedIndex = i; + break; + } + } + + if (e.shiftKey && focusedIndex === 0) { + e.preventDefault(); + focusableChildren[totalFocusable - 1].focus(); + } else if (!e.shiftKey && focusedIndex === totalFocusable - 1) { + e.preventDefault(); + focusableChildren[0].focus(); + } + } + + handleBackdropMouseDown(e) { + this._mouseDownElement = e.target; + } + + handleEscape(e) { + if (this.props.isOpen && e.keyCode === keyCodes.esc && this.props.toggle) { + if (this.props.keyboard) { + e.preventDefault(); + e.stopPropagation(); + + this.props.toggle(e); + } + } + } + + init() { + try { + this._triggeringElement = document.activeElement; + } catch (err) { + this._triggeringElement = null; + } + + if (!this._element) { + this._element = document.createElement('div'); + this._element.setAttribute('tabindex', '-1'); + this._element.style.position = 'relative'; + this._element.style.zIndex = this.props.zIndex; + this._mountContainer = getTarget(this.props.container); + this._mountContainer.appendChild(this._element); + } + + this._originalBodyPadding = getOriginalBodyPadding(); + conditionallyUpdateScrollbar(); + + if (Offcanvas.openCount === 0 && (this.props.backdrop && !this.props.scrollable)) { + document.body.style.overflow = 'hidden'; + } + + this.offcanvasIndex = Offcanvas.openCount; + Offcanvas.openCount += 1; + } + + destroy() { + if (this._element) { + this._mountContainer.removeChild(this._element); + this._element = null; + } + + this.manageFocusAfterClose(); + } + + manageFocusAfterClose() { + if (this._triggeringElement) { + const { returnFocusAfterClose } = this.props; + if (this._triggeringElement.focus && returnFocusAfterClose) this._triggeringElement.focus(); + this._triggeringElement = null; + } + } + + close() { + this.manageFocusAfterClose(); + Offcanvas.openCount = Math.max(0, Offcanvas.openCount - 1); + + document.body.style.overflow = null; + setScrollbarWidth(this._originalBodyPadding); + } + + render() { + const { + direction, + unmountOnClose + } = this.props; + + if (!!this._element && (this.state.isOpen || !unmountOnClose)) { + const isOffcanvasHidden = !!this._element && !this.state.isOpen && !unmountOnClose; + this._element.style.display = isOffcanvasHidden ? 'none' : 'block'; + + const { + className, + backdropClassName, + cssModule, + isOpen, + backdrop, + role, + labelledBy, + style + } = this.props; + + const offcanvasAttributes = { + onKeyUp: this.handleEscape, + onKeyDown: this.handleTab, + 'aria-labelledby': labelledBy, + role, + tabIndex: '-1' + }; + + const hasTransition = this.props.fade; + const offcanvasTransition = { + ...Fade.defaultProps, + ...this.props.offcanvasTransition, + baseClass: hasTransition ? this.props.offcanvasTransition.baseClass : '', + timeout: hasTransition ? this.props.offcanvasTransition.timeout : 0, + }; + const backdropTransition = { + ...Fade.defaultProps, + ...this.props.backdropTransition, + baseClass: hasTransition ? this.props.backdropTransition.baseClass : '', + timeout: hasTransition ? this.props.backdropTransition.timeout : 0, + }; + + const Backdrop = backdrop && ( + hasTransition ? + ( { + this._backdrop = c; + }} + cssModule={cssModule} + className={mapToCssModules(classNames('offcanvas-backdrop', backdropClassName), cssModule)} + onClick={this.handleBackdropClick} + onMouseDown={this.handleBackdropMouseDown} + />) + :
+ ); + + const attributes = omit(this.props, propsToOmit); + + return ( + + { + this._dialog = c; + }} + style={{ + ...style, + visibility: isOpen ? 'visible' : 'hidden' + }} + > + {this.props.children} + + {Backdrop} + + ); + } + return null; + } + + clearBackdropAnimationTimeout() { + if (this._backdropAnimationTimeout) { + clearTimeout(this._backdropAnimationTimeout); + this._backdropAnimationTimeout = undefined; + } + } +} + +Offcanvas.propTypes = propTypes; +Offcanvas.defaultProps = defaultProps; +Offcanvas.openCount = 0; + +export default Offcanvas; diff --git a/src/OffcanvasBody.js b/src/OffcanvasBody.js new file mode 100644 index 000000000..dd908b8ac --- /dev/null +++ b/src/OffcanvasBody.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules, tagPropType } from './utils'; + +const propTypes = { + tag: tagPropType, + className: PropTypes.string, + cssModule: PropTypes.object, +}; + +const defaultProps = { + tag: 'div', +}; + +const OffcanvasBody = (props) => { + const { + className, + cssModule, + tag: Tag, + ...attributes } = props; + const classes = mapToCssModules(classNames( + className, + 'offcanvas-body' + ), cssModule); + + return ( + + ); +}; + +OffcanvasBody.propTypes = propTypes; +OffcanvasBody.defaultProps = defaultProps; + +export default OffcanvasBody; diff --git a/src/OffcanvasHeader.js b/src/OffcanvasHeader.js new file mode 100644 index 000000000..a68cffe63 --- /dev/null +++ b/src/OffcanvasHeader.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules, tagPropType } from './utils'; + +const propTypes = { + children: PropTypes.node, + className: PropTypes.string, + close: PropTypes.object, + closeAriaLabel: PropTypes.string, + cssModule: PropTypes.object, + tag: tagPropType, + toggle: PropTypes.func, + wrapTag: tagPropType +}; + +const defaultProps = { + closeAriaLabel: 'Close', + tag: 'h5', + wrapTag: 'div' +}; + +const OffcanvasHeader = (props) => { + let closeButton; + const { + children, + className, + close, + closeAriaLabel, + cssModule, + tag: Tag, + toggle, + wrapTag: WrapTag, + ...attributes } = props; + + const classes = mapToCssModules(classNames( + className, + 'offcanvas-header' + ), cssModule); + + if (!close && toggle) { + closeButton = ( + + + ); + + jest.runTimersToTime(300); + + expect(isOpen).toBe(true); + expect(document.getElementsByClassName('offcanvas-backdrop').length).toBe(1); + + document.getElementById('clicker').click(); + jest.runTimersToTime(300); + + expect(isOpen).toBe(true); + + const backdrop = document.getElementsByClassName('offcanvas-backdrop')[0]; + + const mouseDownEvent = document.createEvent('MouseEvents'); + mouseDownEvent.initEvent('mousedown', true, true); + backdrop.dispatchEvent(mouseDownEvent); + + const clickEvent = document.createEvent('MouseEvents'); + clickEvent.initEvent('click', true, true); + backdrop.dispatchEvent(clickEvent); + + jest.runTimersToTime(300); + + expect(isOpen).toBe(false); + + wrapper.unmount(); + }); + + it('should destroy this._element', () => { + isOpen = true; + const wrapper = mount( + + + + ); + const instance = wrapper.instance(); + + jest.runTimersToTime(300); + expect(instance._element).toBeTruthy(); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + jest.runTimersToTime(300); + + expect(isOpen).toBe(false); + expect(instance._element).toBe(null); + + wrapper.unmount(); + }); + + it('should destroy this._element when unmountOnClose prop set to true', () => { + isOpen = true; + const wrapper = mount( + + + + ); + const instance = wrapper.instance(); + + jest.runTimersToTime(300); + expect(instance._element).toBeTruthy(); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + jest.runTimersToTime(300); + + expect(isOpen).toBe(false); + expect(instance._element).toBe(null); + + wrapper.unmount(); + }); + + it('should not destroy this._element when unmountOnClose prop set to false', () => { + isOpen = true; + const wrapper = mount( + + + + ); + const instance = wrapper.instance(); + + jest.runTimersToTime(300); + expect(instance._element).toBeTruthy(); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + jest.runTimersToTime(300); + + expect(isOpen).toBe(false); + expect(instance._element).toBeTruthy(); + + wrapper.unmount(); + }); + + it('should destroy this._element on unmount', () => { + isOpen = true; + const wrapper = mount( + + + + ); + const instance = wrapper.instance(); + + jest.runTimersToTime(300); + expect(instance._element).toBeTruthy(); + + wrapper.unmount(); + jest.runTimersToTime(300); + + expect(instance._element).toBe(null); + }); + + it('should remove exactly visibility styles from body', () => { + // set a body class which includes offcanvas-open + document.body.style.background = 'blue'; + + const wrapper = mount( + + Yo! + + ); + + // assert that the offcanvas is closed and the body class is what was set initially + jest.runTimersToTime(300); + expect(isOpen).toBe(false); + expect(document.body.style.background).toBe('blue'); + expect(document.body.style.overflow).toBe(''); + + toggle(); + wrapper.setProps({ + isOpen: true + }); + + // assert that the offcanvas is open and the body class is what was set initially + offcanvas-open + jest.runTimersToTime(300); + expect(isOpen).toBe(true); + wrapper.update(); + expect(document.body.style.background).toBe('blue'); + expect(document.body.style.overflow).toBe('hidden'); + + // append another body class which includes offcanvas-open + // using this to test if replace will leave a space when removing offcanvas-open + document.body.style.color = 'red'; + expect(document.body.style.background).toBe('blue'); + expect(document.body.style.color).toBe('red'); + expect(document.body.style.overflow).toBe('hidden'); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + + // assert that the offcanvas is closed and the body class is what was set initially + jest.runTimersToTime(301); + expect(isOpen).toBe(false); + expect(document.body.style.background).toBe('blue'); + expect(document.body.style.color).toBe('red'); + expect(document.body.style.overflow).toBe(''); + + wrapper.unmount(); + }); + + it('should call onEnter & onExit props if provided', () => { + const onEnter = jest.fn(); + const onExit = jest.fn(); + const wrapper = mount( + + Yo! + + ); + + expect(isOpen).toBe(false); + expect(onEnter).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + + onEnter.mockReset(); + onExit.mockReset(); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + jest.runTimersToTime(300); + + expect(isOpen).toBe(true); + expect(onEnter).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + + onEnter.mockReset(); + onExit.mockReset(); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + jest.runTimersToTime(300); + + wrapper.unmount(); + expect(onEnter).not.toHaveBeenCalled(); + expect(onExit).toHaveBeenCalled(); + }); + + it('should update element z index when prop changes', () => { + const wrapper = shallow( + + Yo! + + ); + expect(wrapper.instance()._element.style.zIndex).toBe('0'); + wrapper.setProps({ zIndex: 1 }); + expect(wrapper.instance()._element.style.zIndex).toBe('1'); + }); + + it('should allow focus on only focusable elements', () => { + isOpen = true; + + const wrapper = mount( + + Offcanvas title + + Test + + test + + + + + + +