From 355d2b86ad246659d399c97eedc356b4b2bb9eb2 Mon Sep 17 00:00:00 2001 From: Steven Scaffidi Date: Mon, 14 May 2018 07:22:48 -0700 Subject: [PATCH] feat(UncontrolledCollapse): add UncontrolledCollapse (#1009) --- docs/lib/Components/CollapsePage.js | 23 +++-- docs/lib/examples/CollapseUncontrolled.js | 21 +++++ package.json | 18 ++-- src/UncontrolledCollapse.js | 58 +++++++++++++ src/__tests__/UncontrolledCollapse.spec.js | 98 ++++++++++++++++++++++ src/__tests__/utils.spec.js | 12 +-- src/index.js | 2 + src/utils.js | 91 +++++++++++++------- 8 files changed, 268 insertions(+), 55 deletions(-) create mode 100644 docs/lib/examples/CollapseUncontrolled.js create mode 100644 src/UncontrolledCollapse.js create mode 100644 src/__tests__/UncontrolledCollapse.spec.js diff --git a/docs/lib/Components/CollapsePage.js b/docs/lib/Components/CollapsePage.js index 50a9cc89a..e59df8036 100644 --- a/docs/lib/Components/CollapsePage.js +++ b/docs/lib/Components/CollapsePage.js @@ -5,9 +5,10 @@ import PageTitle from '../UI/PageTitle'; import SectionTitle from '../UI/SectionTitle'; import CollapseExample from '../examples/Collapse'; -const CollapseExampleSource = require('!!raw!../examples/Collapse'); import CollapseEventsExample from '../examples/CollapseEvents'; + +const CollapseExampleSource = require('!!raw!../examples/Collapse'); const CollapseEventsExampleSource = require('!!raw!../examples/CollapseEvents'); export default class CollapsePage extends React.Component { @@ -19,9 +20,7 @@ export default class CollapsePage extends React.Component {
-          
-            {CollapseExampleSource}
-          
+          {CollapseExampleSource}
         
Properties @@ -44,17 +43,23 @@ export default class CollapsePage extends React.Component { Events

- Use the onEnter, onEntering, onEntered, onExiting and onExited props for callbacks when the - Collapse has finished opening (entering) or closing (exiting). + Use the onEnter, onEntering, onEntered, onExiting and onExited props for + callbacks when the Collapse has finished opening (entering) or closing (exiting).

-          
-            {CollapseEventsExampleSource}
-          
+          {CollapseEventsExampleSource}
         
+ Uncontrolled Collapse +

+ For the most basic use-case, an uncontrolled component can provide the functionality + wanted without the need to manage/control the state of the component.{' '} + UncontrolledCollapse does not require an isOpen prop. Instead + pass a toggler prop. The toggler prop is a string which will run + querySelectorAll to find dom elements which will trigger toggle. +

); } diff --git a/docs/lib/examples/CollapseUncontrolled.js b/docs/lib/examples/CollapseUncontrolled.js new file mode 100644 index 000000000..cfc34fe07 --- /dev/null +++ b/docs/lib/examples/CollapseUncontrolled.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { UncontrolledCollapse, Button, CardBody, Card } from 'reactstrap'; + +const Example = () => ( +
+ + + + + Lorem ipsum dolor sit amet consectetur adipisicing elit. Nesciunt magni, voluptas debitis + similique porro a molestias consequuntur earum odio officiis natus, amet hic, iste sed + dignissimos esse fuga! Minus, alias. + + + +
+); + +export default Example; diff --git a/package.json b/package.json index b3d0c1a2d..6b7319659 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "report-coverage": "coveralls < ./coverage/lcov.info", "test": "cross-env BABEL_ENV=test react-scripts test --env=jsdom", "cover": "npm test -- --coverage", - "start": "cross-env BABEL_ENV=webpack webpack-dev-server --config ./webpack.dev.config.js --watch", - "build-docs": "cross-env BABEL_ENV=webpack WEBPACK_BUILD=production webpack --config ./webpack.dev.config.js --progress --colors", + "start": + "cross-env BABEL_ENV=webpack webpack-dev-server --config ./webpack.dev.config.js --watch", + "build-docs": + "cross-env BABEL_ENV=webpack WEBPACK_BUILD=production webpack --config ./webpack.dev.config.js --progress --colors", "build": "rollup -c", "prebuild": "cross-env BABEL_ENV=lib-dir babel src --out-dir lib --ignore src/__tests__/", "postbuild": "node ./scripts/postbuild.js", @@ -27,14 +29,7 @@ "type": "git", "url": "git+ssh://git@github.com/reactstrap/reactstrap.git" }, - "files": [ - "LICENSE", - "README.md", - "CHANGELOG.md", - "lib", - "dist", - "src" - ], + "files": ["LICENSE", "README.md", "CHANGELOG.md", "lib", "dist", "src"], "keywords": [ "reactstrap", "bootstrap", @@ -86,7 +81,8 @@ "edgji (https://github.com/edgji)", "nlrowe (https://github.com/nlrowe)", "npm-to-cdn-bot (by Forbes Lindesay) (https://github.com/npmcdn-to-unpkg-bot)", - "polmauri (https://github.com/polmauri)" + "polmauri (https://github.com/polmauri)", + "Steven Scaffidi (https://github.com/sscaff1)" ], "license": "MIT", "bugs": { diff --git a/src/UncontrolledCollapse.js b/src/UncontrolledCollapse.js new file mode 100644 index 000000000..408bbe9a6 --- /dev/null +++ b/src/UncontrolledCollapse.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Collapse from './Collapse'; +import { findDOMElements, defaultToggleEvents, addMultipleEventListeners } from './utils'; + +const propTypes = { + toggler: PropTypes.string.isRequired, + toggleEvents: PropTypes.arrayOf(PropTypes.string) +}; + +const defaultProps = { + toggleEvents: defaultToggleEvents +}; + +class UncontrolledCollapse extends Component { + constructor(props) { + super(props); + + this.togglers = null; + this.removeEventListeners = null; + this.toggle = this.toggle.bind(this); + + this.state = { + isOpen: false + }; + } + + componentDidMount() { + this.togglers = findDOMElements(this.props.toggler); + if (this.togglers.length) { + this.removeEventListeners = addMultipleEventListeners( + this.togglers, + this.toggle, + this.props.toggleEvents + ); + } + } + + componentWillUnmount() { + if (this.togglers.length && this.removeEventListeners) { + this.removeEventListeners(); + } + } + + toggle() { + this.setState(({ isOpen }) => ({ isOpen: !isOpen })); + } + + render() { + const { toggleEvents, ...rest } = this.props; + return ; + } +} + +UncontrolledCollapse.propTypes = propTypes; +UncontrolledCollapse.defaultProps = defaultProps; + +export default UncontrolledCollapse; diff --git a/src/__tests__/UncontrolledCollapse.spec.js b/src/__tests__/UncontrolledCollapse.spec.js new file mode 100644 index 000000000..dcd5e102f --- /dev/null +++ b/src/__tests__/UncontrolledCollapse.spec.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { Collapse, UncontrolledCollapse } from '../'; + +describe('UncontrolledCollapse', () => { + let toggler; + let togglers; + + beforeEach(() => { + document.body.innerHTML = ` +
+ + + +
`; + toggler = document.getElementById('toggler'); + togglers = document.getElementsByClassName('toggler'); + }); + + afterEach(() => { + if (jest.isMockFunction(UncontrolledCollapse.prototype.toggle)) { + UncontrolledCollapse.prototype.toggle.mockRestore(); + } + document.body.innerHTML = ''; + toggler = null; + togglers = null; + }); + + it('should be a Collapse', () => { + const collapse = shallow(Yo!); + + expect(collapse.type()).toBe(Collapse); + }); + + it('should have isOpen default to false', () => { + const collapse = shallow(Yo!); + + expect(collapse.prop('isOpen')).toBe(false); + }); + + it('should toggle isOpen when toggle is called', () => { + const collapse = shallow(Yo!); + + toggler.click(); + collapse.update(); + + expect(collapse.prop('isOpen')).toBe(true); + }); + + it('should call toggle when toggler is clicked', () => { + jest.spyOn(UncontrolledCollapse.prototype, 'toggle'); + mount(Yo!); + + expect(UncontrolledCollapse.prototype.toggle.mock.calls.length).toBe(0); + + toggler.click(); + + expect(UncontrolledCollapse.prototype.toggle.mock.calls.length).toBe(1); + }); + + it('should toggle for multiple togglers', () => { + const collapse = shallow(Yo!); + + expect(collapse.prop('isOpen')).toBe(false); + + togglers[0].click(); + collapse.update(); + + expect(collapse.prop('isOpen')).toBe(true); + + togglers[1].click(); + collapse.update(); + + expect(collapse.prop('isOpen')).toBe(false); + }); + + it('should remove eventListeners when unmounted', () => { + jest.spyOn(UncontrolledCollapse.prototype, 'componentWillUnmount'); + jest.spyOn(UncontrolledCollapse.prototype, 'toggle'); + + const wrapper = mount(Yo!); + + expect(UncontrolledCollapse.prototype.toggle.mock.calls.length).toBe(0); + expect(UncontrolledCollapse.prototype.componentWillUnmount.mock.calls.length).toBe(0); + + toggler.click(); + + expect(UncontrolledCollapse.prototype.toggle.mock.calls.length).toBe(1); + + wrapper.unmount(); + + expect(UncontrolledCollapse.prototype.componentWillUnmount.mock.calls.length).toBe(1); + + toggler.click(); + + expect(UncontrolledCollapse.prototype.toggle.mock.calls.length).toBe(1); + }); +}); diff --git a/src/__tests__/utils.spec.js b/src/__tests__/utils.spec.js index bbcff2a4e..188474df9 100644 --- a/src/__tests__/utils.spec.js +++ b/src/__tests__/utils.spec.js @@ -120,20 +120,20 @@ describe('Utils', () => { const element = document.createElement('div'); element.className = 'thing'; document.body.appendChild(element); - jest.spyOn(document, 'querySelector'); + jest.spyOn(document, 'querySelectorAll'); expect(Utils.getTarget('.thing')).toEqual(element); - expect(document.querySelector).toHaveBeenCalledWith('.thing'); - document.querySelector.mockRestore(); + expect(document.querySelectorAll).toHaveBeenCalledWith('.thing'); + document.querySelectorAll.mockRestore(); }); it('should query the document for the id target if the target is a string and could not be found normally', () => { const element = document.createElement('div'); element.setAttribute('id', 'thing'); document.body.appendChild(element); - jest.spyOn(document, 'querySelector'); + jest.spyOn(document, 'querySelectorAll'); expect(Utils.getTarget('thing')).toEqual(element); - expect(document.querySelector).toHaveBeenCalledWith('#thing'); - document.querySelector.mockRestore(); + expect(document.querySelectorAll).toHaveBeenCalledWith('#thing'); + document.querySelectorAll.mockRestore(); }); it('should return the input target if it is not a function nor a string', () => { diff --git a/src/index.js b/src/index.js index 9c75fa2f0..cb64a9a29 100644 --- a/src/index.js +++ b/src/index.js @@ -81,6 +81,7 @@ import ListGroupItemHeading from './ListGroupItemHeading'; import ListGroupItemText from './ListGroupItemText'; import UncontrolledAlert from './UncontrolledAlert'; import UncontrolledButtonDropdown from './UncontrolledButtonDropdown'; +import UncontrolledCollapse from './UncontrolledCollapse'; import UncontrolledDropdown from './UncontrolledDropdown'; import UncontrolledNavDropdown from './UncontrolledNavDropdown'; import UncontrolledTooltip from './UncontrolledTooltip'; @@ -170,6 +171,7 @@ export { ListGroupItemHeading, UncontrolledAlert, UncontrolledButtonDropdown, + UncontrolledCollapse, UncontrolledDropdown, UncontrolledNavDropdown, UncontrolledTooltip, diff --git a/src/utils.js b/src/utils.js index d70b60f83..9859514d6 100644 --- a/src/utils.js +++ b/src/utils.js @@ -26,20 +26,16 @@ export function isBodyOverflowing() { export function getOriginalBodyPadding() { const style = window.getComputedStyle(document.body, null); - return parseInt( - (style && style.getPropertyValue('padding-right')) || 0, - 10 - ); + return parseInt((style && style.getPropertyValue('padding-right')) || 0, 10); } export function conditionallyUpdateScrollbar() { const scrollbarWidth = getScrollbarWidth(); // https://github.com/twbs/bootstrap/blob/v4.0.0-alpha.6/js/src/modal.js#L433 - const fixedContent = document.querySelectorAll('.fixed-top, .fixed-bottom, .is-fixed, .sticky-top')[0]; - const bodyPadding = fixedContent ? parseInt( - fixedContent.style.paddingRight || 0, - 10 - ) : 0; + const fixedContent = document.querySelectorAll( + '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top' + )[0]; + const bodyPadding = fixedContent ? parseInt(fixedContent.style.paddingRight || 0, 10) : 0; if (isBodyOverflowing()) { setScrollbarWidth(bodyPadding + scrollbarWidth); @@ -118,26 +114,6 @@ export function DOMElement(props, propName, componentName) { } } -export function getTarget(target) { - if (isFunction(target)) { - return target(); - } - - if (typeof target === 'string' && document) { - let selection = document.querySelector(target); - if (selection === null) { - selection = document.querySelector(`#${target}`); - } - if (selection === null) { - throw new Error(`The target '${target}' could not be identified in the dom, tip: check spelling`); - } - return selection; - } - - return target; -} - - /* eslint key-spacing: ["error", { afterColon: true, align: "value" }] */ // These are all setup to match what is in the bootstrap _variables.scss // https://github.com/twbs/bootstrap/blob/v4-dev/scss/_variables.scss @@ -205,3 +181,60 @@ export const canUseDOM = !!( window.document && window.document.createElement ); + +export function findDOMElements(target) { + if (isFunction(target)) { + return target(); + } + if (typeof target === 'string' && canUseDOM) { + let selection = document.querySelectorAll(target); + if (!selection.length) { + selection = document.querySelectorAll(`#${target}`); + } + if (!selection.length) { + throw new Error(`The target '${target}' could not be identified in the dom, tip: check spelling`); + } + return selection; + } + return target; +} + +export function isArrayOrNodeList(els) { + return Array.isArray(els) || (canUseDOM && typeof els.length === 'number'); +} + +export function getTarget(target) { + const els = findDOMElements(target); + if (isArrayOrNodeList(els)) { + return els[0]; + } + return els; +} + +export const defaultToggleEvents = ['touchstart', 'click']; + +export function addMultipleEventListeners(els, handler, events) { + if ( + !isArrayOrNodeList(els) || + typeof handler !== 'function' || + !Array.isArray(events) + ) { + throw new Error(` + The first argument of this function must be an array or NodeList. + The second must be a function. + The third is an array of strings that represents DOM events + `); + } + events.forEach((event) => { + els.forEach((el) => { + el.addEventListener(event, handler); + }); + }); + return function removeEvents() { + events.forEach((event) => { + els.forEach((el) => { + el.removeEventListener(event, handler); + }); + }); + }; +}