├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .zuul.yml ├── LICENSE ├── README.md ├── docs ├── index.css ├── index.html └── index.js ├── package.json ├── src ├── components │ ├── Dropdown.jsx │ ├── dropdown-content.jsx │ └── dropdown-trigger.jsx └── docs │ ├── components │ └── account-dropdown.jsx │ ├── index.html │ ├── index.jsx │ └── index.less ├── styles └── Dropdown.css └── test └── components ├── Dropdown.jsx ├── DropdownContent.jsx └── DropdownTrigger.jsx /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2016", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@meadow" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Ignore compiled files 30 | lib 31 | 32 | # I just don't like this file 33 | package-lock.json 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /.zuul.yml: -------------------------------------------------------------------------------- 1 | ui: tape 2 | browsers: 3 | - name: chrome 4 | version: latest 5 | browserify: 6 | - transform: babelify 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Timothy Kempf 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## React Simple Dropdown 2 | 3 | Non-prescriptive React.js dropdown toolkit. 4 | 5 | [See it in action (Demo)](http://fauntleroy.github.io/react-simple-dropdown/) 6 | 7 | ### Installation 8 | 9 | This module is designed for use with [Browserify](http://browserify.org) (but should work with anything CommonJS compatible). You can easily install it with [npm](http://npmjs.com): 10 | 11 | ```bash 12 | npm install react-simple-dropdown 13 | ``` 14 | 15 | ### How to use 16 | 17 | This module provides three React components that you can use as a basis for any kind of dropdown menu: 18 | 19 | - `DropdownTrigger`: The element that will cause your dropdown to appear when clicked. 20 | - `DropdownContent`: Contains the "filling" of your dropdown. Generally, this is a list of links. 21 | - `Dropdown`: The base element for your dropdown. This contains both the `DropdownTrigger` and the `DropdownContent`, and handles communication between them. 22 | 23 | There is also a [barebones stylesheet](styles/Dropdown.css) which **must be included in order for the component to actually work**. 24 | 25 | Keep in mind that `DropdownTrigger` and `DropdownContent` **must be direct children** of `Dropdown`. Here's a quick example: 26 | 27 | ```js 28 | var React = require('react'); 29 | var Dropdown = require('react-simple-dropdown'); 30 | var DropdownTrigger = Dropdown.DropdownTrigger; 31 | var DropdownContent = Dropdown.DropdownContent; 32 | 33 | var Menu = React.createClass({ 34 | render: function () { 35 | return ( 36 | 37 | Profile 38 | 39 | Username 40 | 51 | 52 | 53 | ) 54 | } 55 | }); 56 | ``` 57 | 58 | ### Options 59 | 60 | Options can be passed to `Dropdown` as props. A list of available options can be found below. These must be passed to the containing `Dropdown` component. 61 | 62 | Property | Type | Description 63 | ----- | ----- | ----- 64 | **active** | *boolean* | Manually show/hide the `DropdownContent`. Make sure to unset this or the dropdown will stay open. 65 | **disabled** | *boolean* | Disable toggling of the dropdown by clicking on `DropdownTrigger`. Toggling with `active`, `show()`, and `hide()` is still possible. 66 | **removeElement** | *boolean* | Remove the `DropdownContent` element when inactive (rather than just hide it). 67 | **onShow** | *function* | Callback for when `DropdownContent` is shown. 68 | **onHide** | *function* | Callback for when `DropdownContent` is hidden. 69 | 70 | 71 | ### Instance 72 | 73 | Each instance of `Dropdown` has some methods developers might find useful. 74 | 75 | Method | Description 76 | ----- | ----- 77 | **show** | Shows the dropdown. 78 | **hide** | Hides the dropdown. 79 | **isActive** | Returns a boolean indicating whether or not the dropdown is active. 80 | -------------------------------------------------------------------------------- /docs/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub Gist Theme 3 | * Author : Louis Barranqueiro - https://github.com/LouisBarranqueiro 4 | */ 5 | .hljs { 6 | display: block; 7 | background: white; 8 | padding: 0.5em; 9 | color: #333333; 10 | overflow-x: auto; 11 | } 12 | .hljs-comment, 13 | .hljs-meta { 14 | color: #969896; 15 | } 16 | .hljs-string, 17 | .hljs-variable, 18 | .hljs-template-variable, 19 | .hljs-strong, 20 | .hljs-emphasis, 21 | .hljs-quote { 22 | color: #df5000; 23 | } 24 | .hljs-keyword, 25 | .hljs-selector-tag, 26 | .hljs-type { 27 | color: #a71d5d; 28 | } 29 | .hljs-literal, 30 | .hljs-symbol, 31 | .hljs-bullet, 32 | .hljs-attribute { 33 | color: #0086b3; 34 | } 35 | .hljs-section, 36 | .hljs-name { 37 | color: #63a35c; 38 | } 39 | .hljs-tag { 40 | color: #333333; 41 | } 42 | .hljs-title, 43 | .hljs-attr, 44 | .hljs-selector-id, 45 | .hljs-selector-class, 46 | .hljs-selector-attr, 47 | .hljs-selector-pseudo { 48 | color: #795da3; 49 | } 50 | .hljs-addition { 51 | color: #55a532; 52 | background-color: #eaffea; 53 | } 54 | .hljs-deletion { 55 | color: #bd2c00; 56 | background-color: #ffecec; 57 | } 58 | .hljs-link { 59 | text-decoration: underline; 60 | } 61 | .dropdown { 62 | display: inline-block; 63 | } 64 | .dropdown__content { 65 | display: none; 66 | position: absolute; 67 | } 68 | .dropdown--active .dropdown__content { 69 | display: block; 70 | } 71 | body { 72 | padding: 40px 80px; 73 | font-family: 'Helvetica Neue', Helvetica, 'Segoe UI', Arial, freesans, sans-serif; 74 | } 75 | a { 76 | text-decoration: none; 77 | } 78 | .account-dropdown__avatar { 79 | width: 20px; 80 | height: 20px; 81 | margin: 0 5px 0 0; 82 | border-radius: 3px; 83 | } 84 | .account-dropdown .dropdown__trigger { 85 | line-height: 20px; 86 | } 87 | .account-dropdown .dropdown__trigger:after { 88 | content: '\25BE'; 89 | margin: 0 0 0 10px; 90 | } 91 | .account-dropdown__avatar, 92 | .account-dropdown__name { 93 | vertical-align: middle; 94 | } 95 | .account-dropdown .dropdown__content { 96 | margin-top: 5px; 97 | border-top: rgba(0, 0, 0, 0.05) 1px solid; 98 | background: #ffffff; 99 | box-shadow: 0 5px 8px rgba(0, 0, 0, 0.15); 100 | } 101 | .account-dropdown__quick-links, 102 | .account-dropdown__management-links { 103 | list-style-type: none; 104 | margin: 0; 105 | padding: 0; 106 | } 107 | .account-dropdown__segment { 108 | padding: 10px 15px; 109 | border-bottom: #e1e1e1 1px solid; 110 | } 111 | .account-dropdown__link { 112 | margin: 5px 0; 113 | } 114 | .code { 115 | border: #ebebeb 1px solid; 116 | } 117 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Simple Dropdown 5 | 6 | 7 | 8 |

A Simple Account Dropdown

9 |

The Dropdown

10 |
11 |

The Code

12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-simple-dropdown", 3 | "version": "3.2.3", 4 | "description": "Non-prescriptive React.js dropdown toolkit", 5 | "main": "lib/components/dropdown.js", 6 | "scripts": { 7 | "prepublish": "npm run build", 8 | "postpublish": "npm run clean", 9 | "test": "npm run build && zuul -- test/**/*.{js,jsx}", 10 | "test:browser": "zuul --local 55555 -- test/**/*.{js,jsx}", 11 | "test:electron": "zuul --electron -- test/**/*.{js,jsx}", 12 | "build": "trash lib && babel src/components --out-dir lib/components", 13 | "watch": "npm run build -- -w", 14 | "dev": "npm-run-all --parallel watch test:browser", 15 | "lint": "eslint src --ext .js --ext .jsx", 16 | "clean": "trash lib", 17 | "docs:build": "npm run build && npm-run-all -p docs:build:*", 18 | "docs:build:css": "lessc src/docs/index.less docs/index.css", 19 | "docs:build:js": "browserify -t [ babelify ] -t [ brfs ] --extension=.jsx src/docs/index.jsx --outfile docs/index.js", 20 | "docs:build:html": "ncp src/docs/index.html docs/index.html", 21 | "docs:watch": "npm-run-all -p docs:watch:*", 22 | "docs:watch:css": "autoless src/docs/ docs/", 23 | "docs:watch:js": "watchify -t [ babelify ] -t [ brfs ] --extension=.jsx src/docs/index.jsx --outfile docs/index.js -v", 24 | "docs:watch:html": "sane 'npm run docs:build:html' src/docs/ --glob='index.html'" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/Fauntleroy/react-simple-dropdown.git" 29 | }, 30 | "keywords": [ 31 | "react", 32 | "react-component", 33 | "component", 34 | "dropdown" 35 | ], 36 | "author": { 37 | "name": "Timothy Kempf", 38 | "email": "tim@kemp59f.info", 39 | "url": "http://kempfffffffffffffffffffffffffffffffffffffffffffffffffffffffffff.info" 40 | }, 41 | "license": "ISC", 42 | "bugs": { 43 | "url": "https://github.com/Fauntleroy/react-simple-dropdown/issues" 44 | }, 45 | "homepage": "https://github.com/Fauntleroy/react-simple-dropdown", 46 | "dependencies": { 47 | "classnames": "^2.1.2", 48 | "prop-types": "^15.5.8" 49 | }, 50 | "devDependencies": { 51 | "@meadow/eslint-config": "^2.0.2", 52 | "autoless": "^0.1.7", 53 | "babel": "6.5.2", 54 | "babel-cli": "6.10.1", 55 | "babel-eslint": "^7.2.2", 56 | "babel-preset-es2016": "6.0.11", 57 | "babel-preset-react": "6.5.0", 58 | "babel-preset-stage-0": "6.5.0", 59 | "babelify": "^7.3.0", 60 | "brfs": "^1.4.3", 61 | "browserify": "^13.1.0", 62 | "dom-classes": "0.0.1", 63 | "electron": "^1.6.2", 64 | "eslint": "^3.19.0", 65 | "highlight.js": "^9.6.0", 66 | "less": "^2.7.1", 67 | "mkdirp": "^0.5.1", 68 | "ncp": "^2.0.0", 69 | "npm-run-all": "^1.4.0", 70 | "react": "16.x", 71 | "react-dom": "16.x", 72 | "react-highlight": "^0.9.0", 73 | "sane": "^1.4.1", 74 | "simple-mock": "0.8.0", 75 | "tape": "^4.0.0", 76 | "trash-cli": "^1.2.0", 77 | "watchify": "^3.7.0", 78 | "zuul": "^3.11.1" 79 | }, 80 | "peerDependencies": { 81 | "react": "0.14.x || 15.x || 16.x", 82 | "react-dom": "0.14.x || 15.x || 16.x" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/Dropdown.jsx: -------------------------------------------------------------------------------- 1 | import React, { cloneElement, Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { findDOMNode } from 'react-dom'; 4 | import cx from 'classnames'; 5 | 6 | import DropdownTrigger from './dropdown-trigger.js'; 7 | import DropdownContent from './dropdown-content.js'; 8 | 9 | class Dropdown extends Component { 10 | displayName: 'Dropdown' 11 | 12 | componentDidMount () { 13 | window.addEventListener('click', this._onWindowClick); 14 | window.addEventListener('touchstart', this._onWindowClick); 15 | } 16 | 17 | componentWillUnmount () { 18 | window.removeEventListener('click', this._onWindowClick); 19 | window.removeEventListener('touchstart', this._onWindowClick); 20 | } 21 | 22 | constructor (props) { 23 | super(props); 24 | 25 | this.state = { 26 | active: false 27 | }; 28 | 29 | this._onWindowClick = this._onWindowClick.bind(this); 30 | this._onToggleClick = this._onToggleClick.bind(this); 31 | } 32 | 33 | isActive () { 34 | return (typeof this.props.active === 'boolean') ? 35 | this.props.active : 36 | this.state.active; 37 | } 38 | 39 | hide () { 40 | this.setState({ 41 | active: false 42 | }, () => { 43 | if (this.props.onHide) { 44 | this.props.onHide(); 45 | } 46 | }); 47 | } 48 | 49 | show () { 50 | this.setState({ 51 | active: true 52 | }, () => { 53 | if (this.props.onShow) { 54 | this.props.onShow(); 55 | } 56 | }); 57 | } 58 | 59 | _onWindowClick (event) { 60 | const dropdownElement = findDOMNode(this); 61 | if (event.target !== dropdownElement && !dropdownElement.contains(event.target) && this.isActive()) { 62 | this.hide(); 63 | } 64 | } 65 | 66 | _onToggleClick (event) { 67 | event.preventDefault(); 68 | if (this.isActive()) { 69 | this.hide(); 70 | } else { 71 | this.show(); 72 | } 73 | } 74 | 75 | render () { 76 | const { children, className, disabled, removeElement } = this.props; 77 | // create component classes 78 | const active = this.isActive(); 79 | const dropdownClasses = cx({ 80 | dropdown: true, 81 | 'dropdown--active': active, 82 | 'dropdown--disabled': disabled 83 | }); 84 | // stick callback on trigger element 85 | const boundChildren = React.Children.map(children, child => { 86 | if (child.type === DropdownTrigger) { 87 | const originalOnClick = child.props.onClick; 88 | child = cloneElement(child, { 89 | ref: 'trigger', 90 | onClick: (event) => { 91 | if (!disabled) { 92 | this._onToggleClick(event); 93 | if (originalOnClick) { 94 | originalOnClick.apply(child, arguments); 95 | } 96 | } 97 | } 98 | }); 99 | } else if (child.type === DropdownContent && removeElement && !active) { 100 | child = null; 101 | } 102 | return child; 103 | }); 104 | const cleanProps = { ...this.props }; 105 | delete cleanProps.active; 106 | delete cleanProps.onShow; 107 | delete cleanProps.onHide; 108 | delete cleanProps.removeElement; 109 | 110 | return ( 111 |
114 | {boundChildren} 115 |
116 | ); 117 | } 118 | } 119 | 120 | Dropdown.propTypes = { 121 | disabled: PropTypes.bool, 122 | active: PropTypes.bool, 123 | onHide: PropTypes.func, 124 | onShow: PropTypes.func, 125 | children: PropTypes.node, 126 | className: PropTypes.string, 127 | removeElement: PropTypes.bool, 128 | style: PropTypes.object 129 | }; 130 | 131 | Dropdown.defaultProps = { 132 | className: '' 133 | }; 134 | 135 | export { DropdownTrigger, DropdownContent }; 136 | export default Dropdown; 137 | -------------------------------------------------------------------------------- /src/components/dropdown-content.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class DropdownContent extends Component { 5 | render () { 6 | const { children, className, ...dropdownContentProps } = this.props; 7 | dropdownContentProps.className = `dropdown__content ${className}`; 8 | 9 | return ( 10 |
11 | {children} 12 |
13 | ); 14 | } 15 | } 16 | 17 | DropdownContent.displayName = 'DropdownContent'; 18 | 19 | DropdownContent.propTypes = { 20 | children: PropTypes.node, 21 | className: PropTypes.string 22 | }; 23 | 24 | DropdownContent.defaultProps = { 25 | className: '' 26 | }; 27 | 28 | export default DropdownContent; 29 | -------------------------------------------------------------------------------- /src/components/dropdown-trigger.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class DropdownTrigger extends Component { 5 | render () { 6 | const { children, className, ...dropdownTriggerProps } = this.props; 7 | dropdownTriggerProps.className = `dropdown__trigger ${className}`; 8 | 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | } 16 | 17 | DropdownTrigger.displayName = 'DropdownTrigger'; 18 | 19 | DropdownTrigger.propTypes = { 20 | children: PropTypes.node, 21 | className: PropTypes.string 22 | }; 23 | 24 | DropdownTrigger.defaultProps = { 25 | className: '' 26 | }; 27 | 28 | export default DropdownTrigger; 29 | -------------------------------------------------------------------------------- /src/docs/components/account-dropdown.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Dropdown, { DropdownTrigger, DropdownContent } from '../../../lib/components/dropdown.js'; 4 | 5 | class AccountDropdown extends Component { 6 | constructor (props) { 7 | super(props); 8 | 9 | this.handleLinkClick = this.handleLinkClick.bind(this); 10 | } 11 | 12 | handleLinkClick () { 13 | this.refs.dropdown.hide(); 14 | } 15 | 16 | render () { 17 | const { user } = this.props; 18 | 19 | return ( 20 | 21 | 22 | My Account 23 | 24 | 25 |
26 | Signed in as {user.name} 27 |
28 | 50 | 62 |
63 |
64 | ); 65 | } 66 | } 67 | 68 | AccountDropdown.propTypes = { 69 | user: PropTypes.object.isRequired 70 | }; 71 | 72 | export default AccountDropdown; 73 | -------------------------------------------------------------------------------- /src/docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Simple Dropdown 5 | 6 | 7 | 8 |

A Simple Account Dropdown

9 |

The Dropdown

10 |
11 |

The Code

12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/docs/index.jsx: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); // brfs doesn't play nice with babelify 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import AccountDropdown from './components/account-dropdown.jsx'; 5 | import Highlight from 'react-highlight'; 6 | 7 | const user = { 8 | name: 'Fauntleroy', 9 | avatar_url: 'https://avatars2.githubusercontent.com/u/507047?v=3&s=20' 10 | }; 11 | 12 | // eslint-disable-next-line no-sync 13 | const accountDropdownCode = fs.readFileSync(`${__dirname}/components/account-dropdown.jsx`, 'utf8'); 14 | 15 | ReactDOM.render(, document.getElementById('account-dropdown')); 16 | ReactDOM.render(({accountDropdownCode}), document.getElementById('account-dropdown-code')); 17 | -------------------------------------------------------------------------------- /src/docs/index.less: -------------------------------------------------------------------------------- 1 | @import (less) '../../node_modules/highlight.js/styles/github-gist.css'; 2 | @import (less) '../../styles/Dropdown.css'; 3 | 4 | // General 5 | 6 | body { 7 | padding: 40px 80px; 8 | font-family: 'Helvetica Neue', Helvetica, 'Segoe UI', Arial, freesans, sans-serif; 9 | } 10 | 11 | a { 12 | text-decoration: none; 13 | } 14 | 15 | 16 | // Account Dropdown 17 | 18 | .account-dropdown__avatar { 19 | width: 20px; 20 | height: 20px; 21 | margin: 0 5px 0 0; 22 | border-radius: 3px; 23 | } 24 | 25 | .account-dropdown .dropdown__trigger { 26 | line-height: 20px; 27 | } 28 | 29 | .account-dropdown .dropdown__trigger:after { 30 | content: '\25BE'; 31 | margin: 0 0 0 10px; 32 | } 33 | 34 | .account-dropdown__avatar, 35 | .account-dropdown__name { 36 | vertical-align: middle; 37 | } 38 | 39 | .account-dropdown .dropdown__content { 40 | margin-top: 5px; 41 | border-top: rgba( 0, 0, 0, 0.05 ) 1px solid; 42 | background: rgb( 255, 255, 255 ); 43 | box-shadow: 0 5px 8px rgba( 0, 0, 0, 0.15 ); 44 | } 45 | 46 | .account-dropdown__quick-links, 47 | .account-dropdown__management-links { 48 | list-style-type: none; 49 | margin: 0; 50 | padding: 0; 51 | } 52 | 53 | .account-dropdown__segment { 54 | padding: 10px 15px; 55 | border-bottom: rgb( 225, 225, 225 ) 1px solid; 56 | } 57 | 58 | .account-dropdown__link { 59 | margin: 5px 0; 60 | } 61 | 62 | 63 | // Code 64 | 65 | .code { 66 | border: rgb( 235, 235, 235 ) 1px solid; 67 | } 68 | -------------------------------------------------------------------------------- /styles/Dropdown.css: -------------------------------------------------------------------------------- 1 | .dropdown { 2 | display: inline-block; 3 | } 4 | 5 | .dropdown__content { 6 | display: none; 7 | position: absolute; 8 | } 9 | 10 | .dropdown--active .dropdown__content { 11 | display: block; 12 | } -------------------------------------------------------------------------------- /test/components/Dropdown.jsx: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import React, { Component } from 'react'; 3 | import { findDOMNode } from 'react-dom'; 4 | import { findRenderedComponentWithType, findRenderedDOMComponentWithClass, renderIntoDocument, Simulate } from 'react-dom/test-utils'; 5 | import smock from 'simple-mock'; 6 | import domClasses, { has as hasClass } from 'dom-classes'; 7 | 8 | import Dropdown, { DropdownTrigger, DropdownContent } from '../../lib/components/Dropdown.js'; 9 | 10 | const DEFAULT_TEST_APP_STATE = {}; 11 | 12 | class TestApp extends Component { 13 | constructor () { 14 | super(); 15 | 16 | this.state = DEFAULT_TEST_APP_STATE; 17 | } 18 | 19 | render () { 20 | const { onTriggerClick, ...dropdownState } = this.state; 21 | 22 | return ( 23 | 25 | 26 | 27 | 28 | ); 29 | } 30 | } 31 | 32 | function renderTestApp () { 33 | const testApp = renderIntoDocument(); 34 | const dropdown = findRenderedComponentWithType(testApp, Dropdown); 35 | const dropdownElement = findRenderedDOMComponentWithClass(dropdown, 'dropdown'); 36 | const dropdownElementDomNode = findDOMNode(dropdownElement); 37 | const trigger = findRenderedComponentWithType(testApp, DropdownTrigger); 38 | const triggerElement = findRenderedDOMComponentWithClass(trigger, 'dropdown__trigger'); 39 | const content = findRenderedComponentWithType(testApp, DropdownContent); 40 | const contentElement = findRenderedDOMComponentWithClass(content, 'dropdown__content'); 41 | const contentElementDomNode = findDOMNode(contentElement); 42 | 43 | return { 44 | testApp, 45 | dropdown, 46 | dropdownElement, 47 | dropdownElementDomNode, 48 | trigger, 49 | triggerElement, 50 | content, 51 | contentElement, 52 | contentElementDomNode 53 | }; 54 | } 55 | 56 | test('Merges classes from props with default element class', function (t) { 57 | t.plan(3); 58 | 59 | const { testApp, dropdownElementDomNode } = renderTestApp(); 60 | 61 | t.equal(domClasses(dropdownElementDomNode).length, 1, 'has one class when `className` is empty'); 62 | 63 | testApp.setState({ 64 | className: 'test' 65 | }); 66 | 67 | t.ok(hasClass(dropdownElementDomNode, 'dropdown'), 'has class `dropdown`'); 68 | t.ok(hasClass(dropdownElementDomNode, 'test'), 'has class `test`'); 69 | }); 70 | 71 | test('Transfers props to base element', function (t) { 72 | t.plan(5); 73 | 74 | const id = 'test'; 75 | const className = 'test'; 76 | const dataTest = 'test'; 77 | const onClick = smock.stub(); 78 | 79 | const { testApp, dropdownElementDomNode } = renderTestApp(); 80 | 81 | testApp.setState({ 82 | id, 83 | className, 84 | 'data-test': dataTest, 85 | onClick 86 | }); 87 | 88 | Simulate.click(dropdownElementDomNode); 89 | 90 | t.ok(hasClass(dropdownElementDomNode, 'dropdown'), 'has class `dropdown`'); 91 | t.ok(hasClass(dropdownElementDomNode, 'test'), 'has class `test`'); 92 | t.equal(dropdownElementDomNode.getAttribute('id'), id, 'passes id prop as an attribute to base element'); 93 | t.equal(dropdownElementDomNode.getAttribute('data-test'), dataTest, 'passes arbitrary prop as an attribute to base element'); 94 | t.equal(onClick.callCount, 1, 'passes event handlers'); 95 | }); 96 | 97 | test('Dropdown is toggled when DropdownTrigger is clicked', function (t) { 98 | t.plan(4); 99 | 100 | const { testApp, dropdownElementDomNode, triggerElement } = renderTestApp(); 101 | 102 | const onShowCallback = smock.stub(); 103 | const onHideCallback = smock.stub(); 104 | 105 | testApp.setState({ 106 | onShow: onShowCallback, 107 | onHide: onHideCallback 108 | }); 109 | 110 | Simulate.click(triggerElement); 111 | t.ok(hasClass(dropdownElementDomNode, 'dropdown--active'), 'has class `dropdown--active` after trigger is clicked'); 112 | t.equal(onShowCallback.callCount, 1, '`onShow` function was called'); 113 | Simulate.click(triggerElement); 114 | t.notOk(hasClass(dropdownElementDomNode, 'dropdown--active'), 'does not have class `dropdown--active` after trigger is clicked again'); 115 | t.equal(onHideCallback.callCount, 1, '`onHide` function was called'); 116 | }); 117 | 118 | test('Dropdown is firing onShow only after the dropdown is shown', function (t) { 119 | t.plan(1); 120 | 121 | const { testApp, dropdownElementDomNode, triggerElement } = renderTestApp(); 122 | 123 | const onShowCallback = smock.stub(() => { 124 | t.ok(hasClass(dropdownElementDomNode, 'dropdown--active'), 'has class `dropdown--active` when onShow callback is called.'); 125 | }); 126 | 127 | testApp.setState({ 128 | onShow: onShowCallback 129 | }); 130 | 131 | Simulate.click(triggerElement); 132 | }); 133 | 134 | test('Dropdown is firing onHide only after the dropdown is hidden', function (t) { 135 | t.plan(1); 136 | 137 | const { testApp, dropdownElementDomNode, triggerElement } = renderTestApp(); 138 | 139 | const onHideCallback = smock.stub(() => { 140 | t.notOk(hasClass(dropdownElementDomNode, 'dropdown--active'), 'does not have class `dropdown--active` when onHide callback is called.'); 141 | }); 142 | 143 | testApp.setState({ 144 | onHide: onHideCallback 145 | }); 146 | 147 | Simulate.click(triggerElement); // first click to show the dropdown 148 | Simulate.click(triggerElement); // second click to hide the dropdown 149 | }); 150 | 151 | test('Custom onClick handler is called when DropDownTrigger is clicked', function (t) { 152 | t.plan(1); 153 | 154 | const { testApp, triggerElement } = renderTestApp(); 155 | 156 | const onTriggerClickCallback = smock.stub(); 157 | testApp.setState({ 158 | onTriggerClick: onTriggerClickCallback 159 | }); 160 | Simulate.click(triggerElement); 161 | Simulate.click(triggerElement); 162 | Simulate.click(triggerElement); 163 | 164 | t.equal(onTriggerClickCallback.callCount, 3, 'click handler called when trigger is clicked'); 165 | }); 166 | 167 | test('Dropdown state can be manually set with props', function (t) { 168 | t.plan(2); 169 | 170 | const { testApp, dropdownElementDomNode } = renderTestApp(); 171 | 172 | testApp.setState({ 173 | active: true 174 | }); 175 | 176 | t.ok(hasClass(dropdownElementDomNode, 'dropdown--active'), 'has class `dropdown--active` when `active` is set to `true`'); 177 | 178 | testApp.setState({ 179 | active: false 180 | }); 181 | 182 | t.notOk(hasClass(dropdownElementDomNode, 'dropdown--active'), 'does not have class `dropdown--active` when `active` is set to `false`'); 183 | }); 184 | 185 | test('Dropdown hides itself when area outside dropdown is clicked', function (t) { 186 | t.plan(2); 187 | 188 | const { dropdown, contentElement, dropdownElementDomNode } = renderTestApp(); 189 | 190 | dropdown.setState({ 191 | active: true 192 | }); 193 | 194 | Simulate.click(contentElement); 195 | t.ok(hasClass(dropdownElementDomNode, 'dropdown--active'), 'has class `dropdown--active` after content element is clicked'); 196 | document.body.click(); 197 | t.notOk(hasClass(dropdownElementDomNode, 'dropdown--active'), 'does not have class `dropdown--active` after document body is clicked'); 198 | }); 199 | 200 | test('Dropdown Content element is removed when removeElement is set', function (t) { 201 | t.plan(2); 202 | 203 | const { testApp } = renderTestApp(); 204 | 205 | testApp.setState({ 206 | active: false, 207 | removeElement: true 208 | }); 209 | 210 | try { 211 | findRenderedDOMComponentWithClass(testApp, 'dropdown__content'); 212 | } catch (error) { 213 | t.ok(true, 'content element is not rendered when dropdown is not active'); 214 | } 215 | 216 | testApp.setState({ 217 | active: true 218 | }); 219 | 220 | try { 221 | findRenderedDOMComponentWithClass(testApp, 'dropdown__content'); 222 | } catch (error) { 223 | t.fail('content element is rendered when dropdown is active'); 224 | } finally { 225 | t.pass('content element is rendered when dropdown is active'); 226 | } 227 | }); 228 | 229 | test('DropdownTrigger should do nothing when disabled', function (t) { 230 | t.plan(3); 231 | 232 | const { testApp, dropdown, dropdownElementDomNode, triggerElement } = renderTestApp(); 233 | const customOnClickHandler = smock.stub(); 234 | 235 | testApp.setState({ 236 | disabled: true, 237 | onTriggerClick: customOnClickHandler 238 | }); 239 | 240 | t.ok(hasClass(dropdownElementDomNode, 'dropdown--disabled'), 'has class `dropdown--disabled` when disabled is set'); 241 | 242 | const onToggleClickStub = smock.mock(dropdown, '_onToggleClick'); 243 | 244 | Simulate.click(triggerElement); 245 | 246 | t.equal(onToggleClickStub.callCount, 0, 'prevents _onToggleClick call'); 247 | t.equal(customOnClickHandler.callCount, 0, 'prevents custom onClick call'); 248 | 249 | smock.restore(); 250 | }); 251 | -------------------------------------------------------------------------------- /test/components/DropdownContent.jsx: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import React, { Component } from 'react'; 3 | import { findDOMNode } from 'react-dom'; 4 | import { findRenderedComponentWithType, renderIntoDocument, Simulate } from 'react-dom/test-utils'; 5 | import smock from 'simple-mock'; 6 | import domClasses, { has as hasClass } from 'dom-classes'; 7 | 8 | import DropdownContent from '../../lib/components/DropdownContent.js'; 9 | 10 | class TestApp extends Component { 11 | constructor () { 12 | super(); 13 | 14 | this.state = {}; 15 | } 16 | 17 | render () { 18 | return ; 19 | } 20 | } 21 | 22 | function renderTestApp () { 23 | const testApp = renderIntoDocument(); 24 | const dropdownContent = findRenderedComponentWithType(testApp, DropdownContent); 25 | const dropdownContentDomNode = findDOMNode(dropdownContent); 26 | 27 | return { 28 | testApp, 29 | dropdownContent, 30 | dropdownContentDomNode 31 | }; 32 | } 33 | 34 | test('Merges classes from props with default element class', function (t) { 35 | t.plan(3); 36 | 37 | const { testApp, dropdownContentDomNode } = renderTestApp(); 38 | 39 | t.equal(domClasses(dropdownContentDomNode).length, 1, 'has one class when `className` is empty'); 40 | testApp.setState({ 41 | className: 'test' 42 | }); 43 | t.ok(hasClass(dropdownContentDomNode, 'dropdown__content'), 'has class `dropdown__content`'); 44 | t.ok(hasClass(dropdownContentDomNode, 'test'), 'has class `test`'); 45 | testApp.setState({ 46 | className: null 47 | }); 48 | }); 49 | 50 | test('Transfers props to base element', function (t) { 51 | t.plan(5); 52 | 53 | const { testApp, dropdownContentDomNode } = renderTestApp(); 54 | const id = 'test'; 55 | const className = 'test'; 56 | const dataTest = 'test'; 57 | const onClick = smock.stub(); 58 | 59 | testApp.setState({ 60 | id, 61 | className, 62 | 'data-test': dataTest, 63 | onClick 64 | }); 65 | 66 | Simulate.click(dropdownContentDomNode); 67 | 68 | t.ok(hasClass(dropdownContentDomNode, 'dropdown__content'), 'has class `dropdown`'); 69 | t.ok(hasClass(dropdownContentDomNode, 'test'), 'has class `test`'); 70 | t.equal(dropdownContentDomNode.getAttribute('id'), id, 'passes id prop as an attribute to base element'); 71 | t.equal(dropdownContentDomNode.getAttribute('data-test'), dataTest, 'passes arbitrary prop as an attribute to base element'); 72 | t.equal(onClick.callCount, 1, 'passes event handlers'); 73 | 74 | testApp.setState({ 75 | id: null, 76 | className: null, 77 | onClick: null 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/components/DropdownTrigger.jsx: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import React, { Component } from 'react'; 3 | import { findDOMNode } from 'react-dom'; 4 | import { findRenderedComponentWithType, renderIntoDocument, Simulate } from 'react-dom/test-utils'; 5 | import smock from 'simple-mock'; 6 | import domClasses, { has as hasClass } from 'dom-classes'; 7 | 8 | import DropdownTrigger from '../../lib/components/DropdownTrigger.js'; 9 | 10 | class TestApp extends Component { 11 | constructor () { 12 | super(); 13 | 14 | this.state = {}; 15 | } 16 | 17 | render () { 18 | return ; 19 | } 20 | } 21 | 22 | function renderTestApp () { 23 | const testApp = renderIntoDocument(); 24 | const dropdownTrigger = findRenderedComponentWithType(testApp, DropdownTrigger); 25 | const dropdownTriggerDomNode = findDOMNode(dropdownTrigger); 26 | 27 | return { 28 | testApp, 29 | dropdownTrigger, 30 | dropdownTriggerDomNode 31 | }; 32 | } 33 | 34 | test('Merges classes from props with default element class', function (t) { 35 | t.plan(3); 36 | 37 | const { testApp, dropdownTriggerDomNode } = renderTestApp(); 38 | 39 | t.equal(domClasses(dropdownTriggerDomNode).length, 1, 'has one class when `className` is empty'); 40 | testApp.setState({ 41 | className: 'test' 42 | }); 43 | t.ok(hasClass(dropdownTriggerDomNode, 'dropdown__trigger'), 'has class `dropdown__trigger`'); 44 | t.ok(hasClass(dropdownTriggerDomNode, 'test'), 'has class `test`'); 45 | testApp.setState({ 46 | className: null 47 | }); 48 | }); 49 | 50 | test('Transfers props to base element', function (t) { 51 | t.plan(5); 52 | 53 | const { testApp, dropdownTriggerDomNode } = renderTestApp(); 54 | 55 | const id = 'test'; 56 | const className = 'test'; 57 | const dataTest = 'test'; 58 | const onClick = smock.stub(); 59 | 60 | testApp.setState({ 61 | id, 62 | className, 63 | 'data-test': dataTest, 64 | onClick 65 | }); 66 | 67 | Simulate.click(dropdownTriggerDomNode); 68 | 69 | t.ok(hasClass(dropdownTriggerDomNode, 'dropdown__trigger'), 'has class `dropdown`'); 70 | t.ok(hasClass(dropdownTriggerDomNode, 'test'), 'has class `test`'); 71 | t.equal(dropdownTriggerDomNode.getAttribute('id'), id, 'passes id prop as an attribute to base element'); 72 | t.equal(dropdownTriggerDomNode.getAttribute('data-test'), dataTest, 'passes arbitrary prop as an attribute to base element'); 73 | t.equal(onClick.callCount, 1, 'passes event handlers'); 74 | 75 | testApp.setState({ 76 | id: null, 77 | className: null, 78 | onClick: null 79 | }); 80 | }); 81 | --------------------------------------------------------------------------------