├── .babelrc ├── .eslintrc ├── .gitignore ├── .nycrc ├── .travis.yml ├── README.md ├── dist └── react-16-dropdown.js ├── examples ├── index.html ├── js │ ├── BasicUsage.js │ ├── ComingSoon.js │ ├── Controlled.js │ ├── Customization.js │ ├── Features.js │ ├── Jumbotron.js │ ├── Section.js │ ├── index.js │ └── prism.js └── styles │ ├── examples.css │ └── prism.css ├── package-lock.json ├── package.json ├── src ├── Dropdown.js ├── Menu.js ├── Option.js ├── Trigger.js ├── main.css └── utils.js ├── test ├── .eslintrc ├── .setup.js ├── mocha.opts └── specs │ ├── Option.spec.js │ ├── Trigger.spec.js │ ├── bootstrap.js │ └── utils.spec.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | ["env", { 5 | "targets": { 6 | "browsers": ["last 2 versions", "safari >= 7"] 7 | } 8 | }] 9 | ] 10 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "browser": true 5 | }, 6 | "rules": { 7 | "react/jsx-filename-extension": "off", 8 | "react/prop-types": "off", 9 | "jsx-quotes": "off", 10 | "prefer-template": "off", 11 | "jsx-a11y/click-events-have-key-events": "off", 12 | "jsx-a11y/interactive-supports-focus": "off", 13 | "no-unused-expressions": ["error", { "allowShortCircuit": true }], 14 | "react/no-did-mount-set-state": "off", 15 | "func-names": "off", 16 | "import/no-extraneous-dependencies": "off" 17 | } 18 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | docs 4 | .nyc_output 5 | coverage -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "reporter": [ 3 | "lcov", 4 | "text-summary" 5 | ], 6 | "extension": [ 7 | ".js" 8 | ] 9 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | script: 5 | - npm run test-lint 6 | - npm test 7 | cache: 8 | directories: 9 | - "node_modules" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react-16-dropdown 2 | ================= 3 | 4 | A zero-dependency, lightweight and fully customizable dropdown (not select) for React. You can find examples [here](https://ankit-m.github.io/react-16-dropdown/) 5 | 6 | # Installation 7 | 8 | ```shell 9 | npm install --save react-16-dropdown 10 | ``` 11 | 12 | # Basic usage 13 | 14 | ```javascript 15 | import Dropdown from 'react-16-dropdown'; 16 | 17 | const options = [{ 18 | label: 'Prestige 🎩', 19 | value: 'prestige', 20 | }, { 21 | label: 'Inception 😴', 22 | value: 'inception', 23 | }]; 24 | 25 | console.log(val)} 32 | /> 33 | ``` 34 | 35 | # Supported props 36 | 37 | You can pass the following props to the `Dropdown` component - 38 | 39 | |Name|Default|Allowed values|Description| 40 | |----|------|------|---| 41 | |align|left|left, right|Decides the alignment of the menu w.r.t trigger| 42 | |autoFocus|`false`|`Boolean`|Should the trigger be focused by default| 43 | |className|''|`String`|Adds the given class to the wrapper element| 44 | |closeOnClickOutside|`true`|`Boolean`|Should the dropdown menu close on clicking outside the menu| 45 | |closeOnEscape|`true`|`Boolean`|Should the dropdown menu close on pressing Escape| 46 | |closeOnOptionClick|`true`|`Boolean`|Should the dropdown close when option is clicked| 47 | |disabled|`false`|`Boolean`|Disable the trigger| 48 | |id|`undefined`|`String`|HTML attribute id for the wrapper component| 49 | |focused|`undefined`|`String`|Default focused component in controlled mode| 50 | |menuComponent|`Menu`⁽¹⁾ ⁽²⁾|`ReactElement`|Component to replace the default menu| 51 | |menuPortalTarget|`body`|`String`|Selector for the portal to be attached as a child to| 52 | |menuRenderer|`MenuRenderer`⁽¹⁾|`ReactElement`|Component to render the menu| 53 | |menuSectionRenderer|`MenuRenderer`⁽¹⁾|`ReactElement`|Component to render menu sections| 54 | |onClick*|`undefined`|`Function`|Handler for option click event| 55 | |onClose|`undefined`|`Function`|Function to be called when menu closes| 56 | |onOpen|`undefined`|`Function`|Function to be called when menu opens| 57 | |onMenuKeyDown|`undefined`|`Function`|Function to be called when keydown event is triggered on the menu or bubbled up from option| 58 | |onTriggerClick|`undefined`|`Function`|Function to be called when the trigger element is clicked| 59 | |onTriggerKeyDown|`undefined`|`Function`|Function to be called when a key is pressed on the trigger| 60 | |open|`undefined`|`Boolean`|Prop to control open/closed state of the menu| 61 | |optionComponent|`Option`⁽¹⁾ ⁽²⁾|`ReactElement`|Component to replace the default option| 62 | |optionRenderer|`OptionRenderer`⁽¹⁾|`ReactElement`|Component to render option| 63 | |options*|`undefined`|`Array`|An array of objects| 64 | |portalClassName|''|`String`|Adds the given class to portal component| 65 | |sections|`undefined`|`Array`|Sections array for menu with sections| 66 | |triggerComponent|`Trigger`⁽¹⁾ ⁽²⁾|`ReactElement`|Component to replace the default trigger| 67 | |triggerLabel|Open menu|`String`|Text for the default trigger button| 68 | |triggerRenderer|`TriggerRenderer`⁽¹⁾|`ReactElement`|Component to render the trigger| 69 | 70 | 71 | The `options` prop is an array of objects. Each object can have the following keys - 72 | 73 | |Key|Value|Description| 74 | |----|------|---| 75 | |value*|`String`|Unique identifier for each option| 76 | |label*|<`String`|`ReactElement`>|Display label for the option| 77 | |className|`String`|Custom class name for the option| 78 | |disabled|`Boolean`|Is the option disabled?| 79 | 80 | 81 | In case you are using sections you need to pass the `sections` prop, which is an array of objects. Each object can have the following keys - 82 | 83 | |Key|Value|Description| 84 | |----|------|---| 85 | |id*|`String`|Unique identifier for each section| 86 | |options*|`Array`|Array of options under this section| 87 | |title|<`String`|`ReactElement`>|Title for the section| 88 | |className|`String`|Custom class name for section| 89 | 90 | ⁽¹⁾ Default internal component 91 | 92 | ⁽²⁾ If you replace the component (instead of using renderers), you will have to pass down all the handlers, refs and other props down to your components. 93 | 94 | \* Required props 95 | 96 | # Customization 97 | 98 | You can customize any part of the dropdown to suit your needs. In most cases, modifying existing classes/adding your own classes should do the trick. For advanced use cases, you can use custom render components. If you want to take over individual components of the dropdown, you can replace the `menu`, `option` or `trigger` default components. 99 | 100 | Using renderers - 101 | ```javascript 102 | } 105 | optionRenderer={props =>
{props.label}
} 106 | onClick={e => console.log(e)} 107 | /> 108 | ``` 109 | 110 | Using components - 111 | ```javascript 112 | function CustomButtonComponent(props) { 113 | return ( 114 | 120 | Custom link component 121 | 122 | ); 123 | } 124 | 125 | console.log(e)} 129 | /> 130 | ``` 131 | 132 | # Controlled Component 133 | 134 | You can also use the dropdown as a controlled component if you pass the `open` prop. 135 | 136 | ```javascript 137 | { /* do something */ }} 141 | onClick={e => console.log(e)} 142 | /> 143 | ``` 144 | 145 | # Sections 146 | 147 | Dropdown sections with titles are also supported. Although, you can only have one level of sections. Instead of the `options` array, you need to pass the `sections`, which is an array of sections containing options. You need to pass a unique `id` for each section. 148 | 149 | ```javascript 150 | const sections = [{ 151 | title: 'Movies', 152 | id: 'movies', 153 | options: movieOptions, 154 | }, { 155 | title: 'Fruits', 156 | id: 'fruits', 157 | options: fruitOptions, 158 | }]; 159 | 160 | console.log(e)} 165 | /> 166 | ``` 167 | -------------------------------------------------------------------------------- /dist/react-16-dropdown.js: -------------------------------------------------------------------------------- 1 | (function webpackUniversalModuleDefinition(root, factory) { 2 | if(typeof exports === 'object' && typeof module === 'object') 3 | module.exports = factory(require("react"), require("react-dom")); 4 | else if(typeof define === 'function' && define.amd) 5 | define(["react", "react-dom"], factory); 6 | else if(typeof exports === 'object') 7 | exports["react16Dropdown"] = factory(require("react"), require("react-dom")); 8 | else 9 | root["react16Dropdown"] = factory(root["react"], root["react-dom"]); 10 | })(typeof self !== 'undefined' ? self : this, function(__WEBPACK_EXTERNAL_MODULE_0__, __WEBPACK_EXTERNAL_MODULE_3__) { 11 | return /******/ (function(modules) { // webpackBootstrap 12 | /******/ // The module cache 13 | /******/ var installedModules = {}; 14 | /******/ 15 | /******/ // The require function 16 | /******/ function __webpack_require__(moduleId) { 17 | /******/ 18 | /******/ // Check if module is in cache 19 | /******/ if(installedModules[moduleId]) { 20 | /******/ return installedModules[moduleId].exports; 21 | /******/ } 22 | /******/ // Create a new module (and put it into the cache) 23 | /******/ var module = installedModules[moduleId] = { 24 | /******/ i: moduleId, 25 | /******/ l: false, 26 | /******/ exports: {} 27 | /******/ }; 28 | /******/ 29 | /******/ // Execute the module function 30 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 31 | /******/ 32 | /******/ // Flag the module as loaded 33 | /******/ module.l = true; 34 | /******/ 35 | /******/ // Return the exports of the module 36 | /******/ return module.exports; 37 | /******/ } 38 | /******/ 39 | /******/ 40 | /******/ // expose the modules object (__webpack_modules__) 41 | /******/ __webpack_require__.m = modules; 42 | /******/ 43 | /******/ // expose the module cache 44 | /******/ __webpack_require__.c = installedModules; 45 | /******/ 46 | /******/ // define getter function for harmony exports 47 | /******/ __webpack_require__.d = function(exports, name, getter) { 48 | /******/ if(!__webpack_require__.o(exports, name)) { 49 | /******/ Object.defineProperty(exports, name, { 50 | /******/ configurable: false, 51 | /******/ enumerable: true, 52 | /******/ get: getter 53 | /******/ }); 54 | /******/ } 55 | /******/ }; 56 | /******/ 57 | /******/ // getDefaultExport function for compatibility with non-harmony modules 58 | /******/ __webpack_require__.n = function(module) { 59 | /******/ var getter = module && module.__esModule ? 60 | /******/ function getDefault() { return module['default']; } : 61 | /******/ function getModuleExports() { return module; }; 62 | /******/ __webpack_require__.d(getter, 'a', getter); 63 | /******/ return getter; 64 | /******/ }; 65 | /******/ 66 | /******/ // Object.prototype.hasOwnProperty.call 67 | /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; 68 | /******/ 69 | /******/ // __webpack_public_path__ 70 | /******/ __webpack_require__.p = ""; 71 | /******/ 72 | /******/ // Load entry module and return exports 73 | /******/ return __webpack_require__(__webpack_require__.s = 1); 74 | /******/ }) 75 | /************************************************************************/ 76 | /******/ ([ 77 | /* 0 */ 78 | /***/ (function(module, exports) { 79 | 80 | module.exports = __WEBPACK_EXTERNAL_MODULE_0__; 81 | 82 | /***/ }), 83 | /* 1 */ 84 | /***/ (function(module, exports, __webpack_require__) { 85 | 86 | "use strict"; 87 | 88 | 89 | Object.defineProperty(exports, "__esModule", { 90 | value: true 91 | }); 92 | 93 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 94 | 95 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 96 | 97 | var _react = __webpack_require__(0); 98 | 99 | var _react2 = _interopRequireDefault(_react); 100 | 101 | var _Menu = __webpack_require__(2); 102 | 103 | var _Menu2 = _interopRequireDefault(_Menu); 104 | 105 | var _Trigger = __webpack_require__(5); 106 | 107 | var _Trigger2 = _interopRequireDefault(_Trigger); 108 | 109 | var _utils = __webpack_require__(6); 110 | 111 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 112 | 113 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 114 | 115 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 116 | 117 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 118 | 119 | var Dropdown = function (_Component) { 120 | _inherits(Dropdown, _Component); 121 | 122 | function Dropdown(props) { 123 | _classCallCheck(this, Dropdown); 124 | 125 | var _this = _possibleConstructorReturn(this, (Dropdown.__proto__ || Object.getPrototypeOf(Dropdown)).call(this, props)); 126 | 127 | _this.state = { open: Boolean(props.open) }; 128 | 129 | _this.menuRef = _react2.default.createRef(); 130 | _this.triggerRef = _react2.default.createRef(); 131 | _this.controlled = Object.prototype.hasOwnProperty.call(_this.props, 'open'); 132 | 133 | _this.handleTriggerClick = _this.handleTriggerClick.bind(_this); 134 | _this.handleOptionClick = _this.handleOptionClick.bind(_this); 135 | _this.handleTriggerKeyDown = _this.handleTriggerKeyDown.bind(_this); 136 | _this.handleEscape = _this.handleEscape.bind(_this); 137 | _this.closeMenu = _this.closeMenu.bind(_this); 138 | _this.openMenu = _this.openMenu.bind(_this); 139 | _this.handleClickOutside = _this.handleClickOutside.bind(_this); 140 | _this.setTriggerRect = _this.setTriggerRect.bind(_this); 141 | _this.focusTrigger = _this.focusTrigger.bind(_this); 142 | return _this; 143 | } 144 | 145 | _createClass(Dropdown, [{ 146 | key: 'componentDidMount', 147 | value: function componentDidMount() { 148 | this.setTriggerRect(); 149 | 150 | this.props.autoFocus && this.focusTrigger(); 151 | 152 | _utils.optimizedResize.add(this.setTriggerRect); 153 | } 154 | }, { 155 | key: 'componentDidUpdate', 156 | value: function componentDidUpdate(prevProps, prevState) { 157 | if (this.controlled) { 158 | return; 159 | } 160 | 161 | if (this.state.open && !prevState.open) { 162 | typeof this.props.onOpen === 'function' && this.props.onOpen(); 163 | } 164 | 165 | if (this.state.open) { 166 | this.props.closeOnEscape && document.addEventListener('keyup', this.handleEscape); 167 | this.props.closeOnClickOutside && document.addEventListener('click', this.handleClickOutside); 168 | } else { 169 | this.props.closeOnEscape && document.removeEventListener('keyup', this.handleEscape); 170 | this.props.closeOnClickOutside && document.removeEventListener('click', this.handleClickOutside); 171 | } 172 | } 173 | }, { 174 | key: 'componentWillUnmount', 175 | value: function componentWillUnmount() { 176 | document.removeEventListener('keyup', this.handleEscape); 177 | document.removeEventListener('click', this.handleClickOutside); 178 | } 179 | }, { 180 | key: 'setTriggerRect', 181 | value: function setTriggerRect() { 182 | if (!this.triggerRef.current) { 183 | return; 184 | } 185 | 186 | this.setState({ 187 | triggerBoundingRect: (0, _utils.getAbsoluteBoundingRect)(this.triggerRef.current) 188 | }); 189 | } 190 | 191 | // focus the custom component passed or renderer 192 | 193 | }, { 194 | key: 'focusTrigger', 195 | value: function focusTrigger() { 196 | if (this.props.triggerComponent) { 197 | this.triggerRef.current.focus(); 198 | } else { 199 | this.triggerRef.current.firstChild.focus(); 200 | } 201 | } 202 | }, { 203 | key: 'closeMenu', 204 | value: function closeMenu(focus) { 205 | var _this2 = this; 206 | 207 | this.setState({ open: false }, function () { 208 | focus && _this2.focusTrigger(); 209 | }); 210 | } 211 | }, { 212 | key: 'openMenu', 213 | value: function openMenu() { 214 | this.setState({ open: true }); 215 | } 216 | }, { 217 | key: 'handleClickOutside', 218 | value: function handleClickOutside(e) { 219 | if (!this.menuRef.current) { 220 | return; 221 | } 222 | 223 | if (!this.menuRef.current.contains(e.target)) { 224 | this.closeMenu(); 225 | } 226 | } 227 | }, { 228 | key: 'handleEscape', 229 | value: function handleEscape(e) { 230 | if (e.key === 'Escape') { 231 | this.closeMenu(true); 232 | } 233 | } 234 | }, { 235 | key: 'handleTriggerClick', 236 | value: function handleTriggerClick() { 237 | // re-calculating the position of dropdown to remove scrolling side effects 238 | this.setTriggerRect(); 239 | 240 | typeof this.props.onTriggerClick === 'function' && this.props.onTriggerClick(); 241 | 242 | if (this.controlled) { 243 | return; 244 | } 245 | 246 | this.setState(function (prevState) { 247 | return { open: !prevState.open }; 248 | }); 249 | } 250 | }, { 251 | key: 'handleTriggerKeyDown', 252 | value: function handleTriggerKeyDown(e) { 253 | typeof this.props.onTriggerKeyDown === 'function' && this.props.onTriggerKeyDown(); 254 | 255 | if (this.controlled) { 256 | return; 257 | } 258 | 259 | if (e.key === 'ArrowDown') { 260 | this.openMenu(); 261 | 262 | e.preventDefault(); 263 | } 264 | } 265 | }, { 266 | key: 'handleOptionClick', 267 | value: function handleOptionClick(val) { 268 | typeof this.props.onClick === 'function' && this.props.onClick(val); 269 | 270 | !this.controlled && this.props.closeOnOptionClick && this.closeMenu(true); 271 | } 272 | }, { 273 | key: 'render', 274 | value: function render() { 275 | var TriggerElement = this.props.triggerComponent || _Trigger2.default; 276 | var open = this.controlled ? this.props.open : this.state.open; 277 | var classes = 'react-16-dropdown' + (this.props.className ? ' ' + this.props.className : ''); 278 | 279 | return _react2.default.createElement( 280 | 'div', 281 | { 282 | className: classes, 283 | id: this.props.id 284 | }, 285 | _react2.default.createElement(TriggerElement, { 286 | disabled: this.props.disabled, 287 | label: this.props.triggerLabel, 288 | renderer: this.props.triggerRenderer, 289 | triggerRef: this.triggerRef, 290 | onClick: this.handleTriggerClick, 291 | onKeyDown: this.handleTriggerKeyDown 292 | }), 293 | open && this.state.triggerBoundingRect && _react2.default.createElement(_Menu2.default, _extends({}, this.props, { 294 | controlled: this.controlled, 295 | menuRef: this.menuRef, 296 | triggerBoundingRect: this.state.triggerBoundingRect, 297 | onClick: this.handleOptionClick 298 | })) 299 | ); 300 | } 301 | }]); 302 | 303 | return Dropdown; 304 | }(_react.Component); 305 | 306 | exports.default = Dropdown; 307 | 308 | 309 | Dropdown.defaultProps = { 310 | autoFocus: false, 311 | triggerLabel: 'Open menu', 312 | closeOnEscape: true, 313 | closeOnClickOutside: true, 314 | closeOnOptionClick: false, 315 | disabled: false, 316 | align: 'left', 317 | options: [], 318 | sections: [] 319 | }; 320 | 321 | /***/ }), 322 | /* 2 */ 323 | /***/ (function(module, exports, __webpack_require__) { 324 | 325 | "use strict"; 326 | 327 | 328 | Object.defineProperty(exports, "__esModule", { 329 | value: true 330 | }); 331 | 332 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 333 | 334 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 335 | 336 | var _react = __webpack_require__(0); 337 | 338 | var _react2 = _interopRequireDefault(_react); 339 | 340 | var _reactDom = __webpack_require__(3); 341 | 342 | var _reactDom2 = _interopRequireDefault(_reactDom); 343 | 344 | var _Option = __webpack_require__(4); 345 | 346 | var _Option2 = _interopRequireDefault(_Option); 347 | 348 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 349 | 350 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 351 | 352 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 353 | 354 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 355 | 356 | /** 357 | * Default menu renderer 358 | * 359 | * @param {Object} props - React props 360 | * @param {ReactElement} props.children - Options to render 361 | * @returns {ReactElement} menu 362 | */ 363 | function MenuRenderer(props) { 364 | return props.children; 365 | } 366 | 367 | /** 368 | * Default component for menu section 369 | * 370 | * @param {Object} props - React props 371 | * @param {String} props.title - Section title 372 | * @param {ReactElement} props.children - Options in the section 373 | * @returns {ReactElement} menu section 374 | */ 375 | function MenuSectionRenderer(props) { 376 | var className = 'menu-section' + (props.className ? ' ' + props.className : ''); 377 | 378 | return _react2.default.createElement( 379 | 'div', 380 | { className: className }, 381 | _react2.default.createElement( 382 | 'div', 383 | { className: 'menu-section__title' }, 384 | props.title 385 | ), 386 | _react2.default.createElement( 387 | 'div', 388 | { className: 'menu-section__body' }, 389 | props.children 390 | ) 391 | ); 392 | } 393 | 394 | /** 395 | * Default menu component 396 | * 397 | * @param {Object} props - React props 398 | * @param {ReactElement} props.renderer - Menu renderer 399 | * @param {ReactRef} props.menuRef - Ref for the menu component 400 | * @param {Object} props.style - Inline styles for menu 401 | * @param {Function} props.onKeyDown - Handler for keyboard events 402 | * @param {ReactElement} props.children - Option elements 403 | */ 404 | function Menu(props) { 405 | var Renderer = props.renderer; 406 | 407 | return _react2.default.createElement( 408 | 'div', 409 | { 410 | className: 'menu', 411 | role: 'listbox', 412 | ref: props.menuRef, 413 | tabIndex: -1, 414 | style: props.style, 415 | onKeyDown: props.onKeyDown 416 | }, 417 | _react2.default.createElement( 418 | Renderer, 419 | null, 420 | props.children 421 | ) 422 | ); 423 | } 424 | 425 | /** 426 | * Portal for the menu 427 | * 428 | * @help https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Using_tabindex 429 | */ 430 | 431 | var MenuPortal = function (_Component) { 432 | _inherits(MenuPortal, _Component); 433 | 434 | function MenuPortal(props) { 435 | _classCallCheck(this, MenuPortal); 436 | 437 | var _this = _possibleConstructorReturn(this, (MenuPortal.__proto__ || Object.getPrototypeOf(MenuPortal)).call(this, props)); 438 | 439 | _this.state = { focused: -1 }; 440 | 441 | _this.optionRefs = {}; 442 | _this.el = document.createElement('div'); 443 | _this.el.classList.add('react-16-dropdown-portal'); 444 | props.portalClassName && _this.el.classList.add(props.portalClassName); 445 | 446 | _this.handleKeyDown = _this.handleKeyDown.bind(_this); 447 | _this.getAlignment = _this.getAlignment.bind(_this); 448 | _this.setOptionRefs = _this.setOptionRefs.bind(_this); 449 | _this.getOptions = _this.getOptions.bind(_this); 450 | _this.getOptionElements = _this.getOptionElements.bind(_this); 451 | return _this; 452 | } 453 | 454 | _createClass(MenuPortal, [{ 455 | key: 'componentDidMount', 456 | value: function componentDidMount() { 457 | document.querySelector(this.props.menuPortalTarget).appendChild(this.el); 458 | 459 | this.props.controlled && this.props.focused && this.optionRefs[this.props.focused].focus(); 460 | 461 | if (!this.props.controlled || this.props.autoFocusMenu) { 462 | this.props.menuRef.current.focus(); 463 | } 464 | } 465 | }, { 466 | key: 'componentDidUpdate', 467 | value: function componentDidUpdate() { 468 | var options = this.getOptions(); 469 | 470 | var key = void 0; 471 | 472 | if (!this.props.controlled) { 473 | var selected = options[this.state.focused]; 474 | 475 | key = selected && selected.value; 476 | } else { 477 | key = this.props.focused; 478 | } 479 | 480 | key && this.optionRefs[key].focus(); 481 | } 482 | }, { 483 | key: 'componentWillUnmount', 484 | value: function componentWillUnmount() { 485 | document.querySelector(this.props.menuPortalTarget).removeChild(this.el); 486 | } 487 | }, { 488 | key: 'getAlignment', 489 | value: function getAlignment() { 490 | // @todo allow other alignments 491 | var boundingRect = this.props.triggerBoundingRect; 492 | var top = boundingRect.top + boundingRect.height; 493 | 494 | if (this.props.align === 'left') { 495 | return { 496 | top: top, 497 | left: boundingRect.left 498 | }; 499 | } 500 | 501 | if (this.props.align === 'right') { 502 | return { 503 | top: top, 504 | right: window.innerWidth - boundingRect.right - window.scrollX 505 | }; 506 | } 507 | 508 | return {}; 509 | } 510 | }, { 511 | key: 'getOptions', 512 | value: function getOptions() { 513 | var _props = this.props, 514 | options = _props.options, 515 | sections = _props.sections; 516 | 517 | 518 | if (sections.length) { 519 | return sections.reduce(function (res, sec) { 520 | return res.concat(sec.options); 521 | }, []); 522 | } 523 | 524 | return options; 525 | } 526 | }, { 527 | key: 'getOptionElements', 528 | value: function getOptionElements() { 529 | var _this2 = this; 530 | 531 | var sections = this.props.sections; 532 | 533 | var options = this.getOptions(); 534 | var OptionElement = this.props.optionComponent; 535 | var SectionRenderer = this.props.menuSectionRenderer; 536 | var focused = this.props.controlled ? options.map(function (o) { 537 | return o.value; 538 | }).indexOf(this.props.focused) : this.state.focused; 539 | 540 | if (sections.length) { 541 | return sections.map(function (sec, i) { 542 | return _react2.default.createElement( 543 | SectionRenderer, 544 | _extends({}, sec, { 545 | key: sec.id 546 | }), 547 | sec.options.map(function (option, j) { 548 | return _react2.default.createElement(OptionElement, { 549 | className: option.className, 550 | data: option, 551 | focused: focused === i * (i + 1) + j, 552 | key: option.value, 553 | optionRef: function optionRef(node) { 554 | return _this2.setOptionRefs(node, option.value); 555 | }, 556 | renderer: _this2.props.optionRenderer, 557 | onClick: function onClick() { 558 | _this2.props.onClick(option); 559 | } 560 | }); 561 | }) 562 | ); 563 | }); 564 | } 565 | 566 | return options.map(function (option, i) { 567 | return _react2.default.createElement(OptionElement, { 568 | className: option.className, 569 | data: option, 570 | focused: focused === i, 571 | key: option.value, 572 | optionRef: function optionRef(node) { 573 | return _this2.setOptionRefs(node, option.value); 574 | }, 575 | renderer: _this2.props.optionRenderer, 576 | onClick: function onClick() { 577 | _this2.props.onClick(option); 578 | } 579 | }); 580 | }); 581 | } 582 | }, { 583 | key: 'setOptionRefs', 584 | value: function setOptionRefs(node, key) { 585 | node && (this.optionRefs[key] = node); 586 | } 587 | }, { 588 | key: 'handleKeyDown', 589 | value: function handleKeyDown(e) { 590 | typeof this.props.onMenuKeyDown === 'function' && this.props.onMenuKeyDown(e); 591 | 592 | if (this.props.controlled) { 593 | return; 594 | } 595 | 596 | var options = this.getOptions(); 597 | var maxFocus = options.length - 1; 598 | var focusedOption = options[this.state.focused]; 599 | 600 | // NOTE: This method is called when the menu is 601 | // opened with the keyboard. This case handles it 602 | if (e.key === 'Enter' && focusedOption && !focusedOption.disabled) { 603 | this.props.onClick(focusedOption.value); 604 | } else if (e.key === 'ArrowDown') { 605 | this.setState(function (prevState) { 606 | return { 607 | focused: prevState.focused < maxFocus ? prevState.focused + 1 : maxFocus 608 | }; 609 | }); 610 | } else if (e.key === 'ArrowUp') { 611 | this.setState(function (prevState) { 612 | return { 613 | focused: prevState.focused > 0 ? prevState.focused - 1 : 0 614 | }; 615 | }); 616 | } 617 | 618 | e.preventDefault(); 619 | } 620 | }, { 621 | key: 'render', 622 | value: function render() { 623 | var MenuElement = this.props.menuComponent; 624 | 625 | var menu = _react2.default.createElement( 626 | MenuElement, 627 | { 628 | menuRef: this.props.menuRef, 629 | renderer: this.props.menuRenderer, 630 | style: this.getAlignment(), 631 | onKeyDown: this.handleKeyDown 632 | }, 633 | this.getOptionElements() 634 | ); 635 | 636 | return _reactDom2.default.createPortal(menu, this.el); 637 | } 638 | }]); 639 | 640 | return MenuPortal; 641 | }(_react.Component); 642 | 643 | exports.default = MenuPortal; 644 | 645 | 646 | MenuPortal.defaultProps = { 647 | menuComponent: Menu, 648 | optionComponent: _Option2.default, 649 | menuRenderer: MenuRenderer, 650 | menuSectionRenderer: MenuSectionRenderer, 651 | menuPortalTarget: 'body' 652 | }; 653 | 654 | /***/ }), 655 | /* 3 */ 656 | /***/ (function(module, exports) { 657 | 658 | module.exports = __WEBPACK_EXTERNAL_MODULE_3__; 659 | 660 | /***/ }), 661 | /* 4 */ 662 | /***/ (function(module, exports, __webpack_require__) { 663 | 664 | "use strict"; 665 | 666 | 667 | Object.defineProperty(exports, "__esModule", { 668 | value: true 669 | }); 670 | 671 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 672 | 673 | exports.OptionRenderer = OptionRenderer; 674 | exports.default = Option; 675 | 676 | var _react = __webpack_require__(0); 677 | 678 | var _react2 = _interopRequireDefault(_react); 679 | 680 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 681 | 682 | /** 683 | * Default renderer for option. Renders a div with 684 | * child as label. 685 | * 686 | * @param {Object} props - React props 687 | * @param {String} props.className - Custom class 688 | * @param {Boolean} props.focused - Is focused? 689 | * @param {String|ReactElement} props.label - Option label 690 | * @returns {ReactElement} 691 | */ 692 | function OptionRenderer(props) { 693 | var classes = 'option' + (props.focused ? ' focused' : '') + (props.disabled ? ' disabled' : '') + (props.className ? ' ' + props.className : ''); 694 | 695 | return _react2.default.createElement( 696 | 'div', 697 | { className: classes }, 698 | props.label 699 | ); 700 | } 701 | 702 | /** 703 | * Default option component. It renders a div with 704 | * renderer as a child. 705 | * 706 | * @param {Object} props - React props 707 | * @param {Boolean} props.focused - Is option focused? 708 | * @param {ReactRef} props.optionRef - React ref for option 709 | * @param {Function} props.onClick - Click handler 710 | * @param {Object} props.data - Option data 711 | * @param {String} props.className - Custom class 712 | * @returns {ReactElement} 713 | */ 714 | function Option(props) { 715 | var Renderer = props.renderer; 716 | 717 | return _react2.default.createElement( 718 | 'div', 719 | { 720 | 'aria-selected': props.focused, 721 | role: 'option', 722 | tabIndex: -1, 723 | ref: props.optionRef, 724 | onClick: props.data.disabled ? undefined : props.onClick 725 | }, 726 | _react2.default.createElement(Renderer, _extends({}, props.data, { 727 | className: props.className, 728 | focused: props.focused 729 | })) 730 | ); 731 | } 732 | 733 | Option.defaultProps = { 734 | renderer: OptionRenderer, 735 | data: {} 736 | }; 737 | 738 | /***/ }), 739 | /* 5 */ 740 | /***/ (function(module, exports, __webpack_require__) { 741 | 742 | "use strict"; 743 | 744 | 745 | Object.defineProperty(exports, "__esModule", { 746 | value: true 747 | }); 748 | exports.TriggerRenderer = TriggerRenderer; 749 | exports.default = Trigger; 750 | 751 | var _react = __webpack_require__(0); 752 | 753 | var _react2 = _interopRequireDefault(_react); 754 | 755 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 756 | 757 | /** 758 | * Default trigger renderer - Displays a plain button 759 | * with label 760 | * 761 | * @param {Object} props - React props 762 | * @param {Boolean} props.disabled - Trigger disabled 763 | * @param {String|ReactElement} props.label - Trigger display label 764 | */ 765 | function TriggerRenderer(props) { 766 | return _react2.default.createElement( 767 | 'button', 768 | { 769 | className: 'trigger-renderer', 770 | disabled: props.disabled 771 | }, 772 | props.label 773 | ); 774 | } 775 | 776 | /** 777 | * Default trigger component - Renders a div with 778 | * all the handlers 779 | * 780 | * @param {Object} props - React props 781 | * @param {ReactElement} [props.renderer] - Custom trigger renderer 782 | * @param {ReactRef} props.triggerRef - React ref for trigger 783 | * @param {Function} props.onClick - Click handler 784 | * @param {Function} props.onKeyDown - Key down handler 785 | * @param {Boolean} props.disabled - Trigger disabled 786 | * @param {String|ReactElement} props.label - Trigger display label 787 | */ 788 | function Trigger(props) { 789 | var Renderer = props.renderer || TriggerRenderer; 790 | 791 | return _react2.default.createElement( 792 | 'div', 793 | { 794 | className: 'trigger', 795 | ref: props.triggerRef, 796 | role: 'button', 797 | onClick: props.onClick, 798 | onKeyDown: props.onKeyDown 799 | }, 800 | _react2.default.createElement(Renderer, { 801 | disabled: props.disabled, 802 | label: props.label 803 | }) 804 | ); 805 | } 806 | 807 | /***/ }), 808 | /* 6 */ 809 | /***/ (function(module, exports, __webpack_require__) { 810 | 811 | "use strict"; 812 | 813 | 814 | Object.defineProperty(exports, "__esModule", { 815 | value: true 816 | }); 817 | exports.getAbsoluteBoundingRect = getAbsoluteBoundingRect; 818 | var optimizedResize = exports.optimizedResize = function () { 819 | var callbacks = []; 820 | var running = false; 821 | 822 | // run the actual callbacks 823 | function runCallbacks() { 824 | callbacks.forEach(function (callback) { 825 | callback(); 826 | }); 827 | 828 | running = false; 829 | } 830 | 831 | // fired on resize event 832 | function resize() { 833 | if (!running) { 834 | running = true; 835 | 836 | if (window.requestAnimationFrame) { 837 | window.requestAnimationFrame(runCallbacks); 838 | } else { 839 | setTimeout(runCallbacks, 66); 840 | } 841 | } 842 | } 843 | 844 | // adds callback to loop 845 | function addCallback(callback) { 846 | if (callback) { 847 | callbacks.push(callback); 848 | } 849 | } 850 | 851 | return { 852 | // public method to add additional callback 853 | add: function add(callback) { 854 | if (!callbacks.length) { 855 | window.addEventListener('resize', resize); 856 | } 857 | addCallback(callback); 858 | } 859 | }; 860 | }(); 861 | 862 | function getAbsoluteBoundingRect(el) { 863 | var clientRect = el.getBoundingClientRect(); 864 | var rect = {}; 865 | 866 | rect.left = window.scrollX + clientRect.left; 867 | rect.top = window.scrollY + clientRect.top; 868 | rect.right = clientRect.right; 869 | rect.bottom = clientRect.bottom; 870 | rect.height = clientRect.height; 871 | 872 | return rect; 873 | } 874 | 875 | /***/ }) 876 | /******/ ]); 877 | }); -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React 16 Dropdown 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/js/BasicUsage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Dropdown from 'react-16-dropdown'; 4 | 5 | export default function BasicUsage() { 6 | const movieOptions = [{ 7 | label: 'Prestige 🎩', 8 | value: 'prestige', 9 | disabled: true 10 | }, { 11 | label: 'Inception 😴', 12 | value: 'inception' 13 | }]; 14 | const fruitOptions = [{ 15 | label: 'Banana 🍌', 16 | value: 'banana', 17 | }, { 18 | label: 'Apple 🍎', 19 | value: 'apple', 20 | }, { 21 | label: 'Watermelon 🍉', 22 | value: 'watermelon', 23 | }]; 24 | const vehicleOptions = [{ 25 | label: 'Car 🚗', 26 | value: 'car', 27 | }, { 28 | label: 'Truck 🚛', 29 | value: 'truck', 30 | }]; 31 | 32 | return ( 33 |
34 |
35 |

Basic Usage

36 |

37 | To get started with dropdown, all you need to pass is 38 | an options array and onClick function. 39 | Check documentation for all supported props. 40 |

41 |
42 | console.log(e)} 50 | /> 51 | { console.log(e); }} 56 | /> 57 | { console.log(e); }} 63 | /> 64 |
65 | 66 |
67 |           
68 |             {
69 | `const options = [{
70 |   label: 'Prestige 🎩',
71 |   value: 'prestige',
72 |   disabled: true
73 | }, {
74 |   label: 'Inception 😴',
75 |   value: 'inception',
76 | }];
77 | 
78 |  console.log(val)}
85 | />`
86 |             }
87 |           
88 |         
89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /examples/js/ComingSoon.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankit-m/react-16-dropdown/9da1f343f4a19611e61582b19603a9fcbb971e46/examples/js/ComingSoon.js -------------------------------------------------------------------------------- /examples/js/Controlled.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Dropdown from 'react-16-dropdown'; 4 | 5 | export default function Controlled() { 6 | const fruitOptions = [{ 7 | label: 'Orange 🍊', 8 | value: 'orange', 9 | }, { 10 | label: 'Green Apple 🍏', 11 | value: 'green-apple', 12 | }]; 13 | 14 | return ( 15 |
16 |
17 |

Controlled Component

18 |

19 | By default, the dropdown ships a controlled component. 20 | There may be cases where you might want to use it as a controlled 21 | component. You can pass props open, onTriggerClick, etc. 22 |

23 | 24 |
25 | console.log('trigger click')} 31 | onTriggerKeyDown={() => console.log('trigger keydown')} 32 | onMenuKeyDown={() => console.log('menu keydown')} 33 | onClick={(e) => { console.log('option click', e); }} 34 | /> 35 |
36 |
37 |
38 |
39 | 40 |
41 |           
42 |             {
43 | `// Custom trigger component
44 |  console.log(e)}
49 | />`
50 |             }
51 |           
52 |         
53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /examples/js/Customization.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Dropdown from 'react-16-dropdown'; 4 | 5 | export default function Customization() { 6 | const options = [{ 7 | label: 'Inception', 8 | value: 'inception', 9 | }, { 10 | label: 'Prestige', 11 | value: 'prestige', 12 | }]; 13 | const colorOptions = [{ 14 | label: 'Primary', 15 | value: 'primary', 16 | }, { 17 | label: 'Success', 18 | value: 'success', 19 | }, { 20 | label: 'Danger', 21 | value: 'danger', 22 | }]; 23 | 24 | return ( 25 |
26 |
27 |

Customization

28 |

29 | You can customize the dropdown to suit your needs. There are three 30 | options to do this: 31 |

32 |
    33 |
  • 34 | Append styles to existing classes - trigger, 35 |  menu and option 36 |
  • 37 |
  • Use your own renderers
  • 38 |
  • Replace default components with your own custom components
  • 39 |
40 | 41 |

Using renderers

42 |

43 | In most cases using custom renderers should suffice. You just have 44 | pass a presentational component in triggerRenderer, 45 |  menuRenderer or optionRenderer. 46 | These components will receive basic props. 47 |

48 |
49 | } 52 | onClick={e => console.log(e)} 53 | /> 54 | 55 | } 58 | optionRenderer={props =>
{props.label}
} 59 | onClick={e => console.log(e)} 60 | /> 61 |
62 |
 63 |           
 64 |             {
 65 | `// Custom trigger renderer
 66 |  }
 69 |   onClick={e => console.log(e)}
 70 | />
 71 | 
 72 | // Custom option renderer
 73 |  }
 76 |   optionRenderer={props => 
{props.label}
} 77 | onClick={e => console.log(e)} 78 | />` 79 | } 80 |
81 |
82 | 83 |

Replacing components

84 |

85 | In some cases, you might want to replace the default components. The component 86 | will be passed all props (including refs and event handlers). You should apply 87 | the appropriate props for correct functionality. 88 |

89 | 90 |
91 | ( 94 | 95 | Custom link component 96 | 97 | )} 98 | onClick={e => console.log(e)} 99 | /> 100 | 101 | ( 106 |
111 | {props.data.label} 112 |
113 | )} 114 | onClick={e => console.log(e)} 115 | /> 116 |
117 |
118 |           
119 |             {
120 | `// Custom trigger component
121 |  (
124 |     
125 |       Custom link component
126 |     
127 |   )}
128 |   onClick={e => console.log(e)}
129 | />
130 | 
131 | // Custom option component
132 |  (
137 |     
142 | {props.label} 143 |
144 | )} 145 | onClick={e => console.log(e)} 146 | />` 147 | } 148 |
149 |
150 |
151 |
152 | ); 153 | } -------------------------------------------------------------------------------- /examples/js/Features.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Features() { 4 | return ( 5 |
6 |
7 |

Features

8 | 9 |
    10 |
  • Light weight: Zero external dependencies which makes the library super light.
  • 11 |
  • Keyboard support: Built in support for keyboard navigation for accesibility.
  • 12 |
  • Portals: Uses portal based implementation for menus.
  • 13 |
  • Fully customizable: Add your own renderers and components for trigger, menu and option.
  • 14 |
  • Option groups: Add sections to to dropdown options.
  • 15 |
  • Multi-level menus: Coming soon
  • 16 |
17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /examples/js/Jumbotron.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Dropdown from 'react-16-dropdown'; 4 | 5 | function TwitterButton() { 6 | return ( 7 | 12 | Follow @ankit_muchhala 13 | 14 | ); 15 | } 16 | 17 | function GithubButton() { 18 | return ( 19 | 24 | GitHub 25 | 26 | ); 27 | } 28 | 29 | /** 30 | * Introductory component 31 | */ 32 | export default function Jumbotron() { 33 | const options = [{ 34 | label: 'Banana 🍌', 35 | value: 'banana', 36 | }, { 37 | label: 'Apple 🍎', 38 | value: 'apple', 39 | }, { 40 | label: 'Watermelon 🍉', 41 | value: 'watermelon', 42 | }]; 43 | 44 | return ( 45 |
46 |
47 |
48 |
49 |

react 16 dropdown

50 |

Zero-dependency, lightweight and fully cuztomizable dropdown (not select) for React.

51 | npm install --save react-16-dropdown 52 |
53 | 54 |    55 | 56 |
57 |
58 |
59 | 64 |
65 |
66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /examples/js/Section.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Dropdown from 'react-16-dropdown'; 4 | 5 | export default function BasicUsage() { 6 | const movieOptions = [{ 7 | label: 'Prestige 🎩', 8 | value: 'prestige', 9 | }, { 10 | label: 'Inception 😴', 11 | value: 'inception', 12 | }]; 13 | const fruitOptions = [{ 14 | label: 'Banana 🍌', 15 | value: 'banana', 16 | }, { 17 | label: 'Watermelon 🍉', 18 | value: 'watermelon', 19 | }]; 20 | const sections = [{ 21 | title: 'Movies', 22 | id: 'movies', 23 | options: movieOptions, 24 | }, { 25 | title: 'Fruits', 26 | id: 'fruits', 27 | options: fruitOptions, 28 | }]; 29 | 30 | return ( 31 |
32 |
33 |

Sections

34 |

35 | To get started with dropdown, all you need to pass is 36 | an options array and onClick function. 37 | Check documentation for all supported props. 38 |

39 |
40 | console.log(e)} 45 | /> 46 |
47 | 48 |
49 |           
50 |             {
51 | `const movieOptions = [{
52 |   label: 'Prestige 🎩',
53 |   value: 'prestige',
54 | }, {
55 |   label: 'Inception 😴',
56 |   value: 'inception',
57 | }];
58 | const fruitOptions = [{
59 |   label: 'Banana 🍌',
60 |   value: 'banana',
61 | }, {
62 |   label: 'Watermelon 🍉',
63 |   value: 'watermelon',
64 | }];
65 | const sections = [{
66 |   title: 'Movies',
67 |   id: 'movies',
68 |   options: movieOptions,
69 | }, {
70 |   title: 'Fruits',
71 |   id: 'fruits',
72 |   options: fruitOptions,
73 | }];
74 | 
75 |  console.log(e)}
80 | />`
81 |             }
82 |           
83 |         
84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /examples/js/index.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import React, { Fragment } from 'react'; 3 | 4 | import '../styles/examples.css'; 5 | import '../styles/prism.css'; 6 | import '../../src/main.css'; 7 | 8 | import './prism'; 9 | 10 | import Jumbotron from './Jumbotron'; 11 | import BasicUsage from './BasicUsage'; 12 | import Customization from './Customization'; 13 | import Controlled from './Controlled'; 14 | import Features from './Features'; 15 | import Section from './Section'; 16 | 17 | function App() { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | ); 28 | } 29 | 30 | ReactDOM.render(, document.getElementById('react-app')); 31 | -------------------------------------------------------------------------------- /examples/js/prism.js: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.14.0 2 | https://prismjs.com/download.html#themes=prism-okaidia&languages=markup+clike+javascript+jsx */ 3 | var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(){var e=/\blang(?:uage)?-([\w-]+)\b/i,t=0,n=_self.Prism={manual:_self.Prism&&_self.Prism.manual,disableWorkerMessageHandler:_self.Prism&&_self.Prism.disableWorkerMessageHandler,util:{encode:function(e){return e instanceof r?new r(e.type,n.util.encode(e.content),e.alias):"Array"===n.util.type(e)?e.map(n.util.encode):e.replace(/&/g,"&").replace(/e.length)return;if(!(w instanceof s)){if(m&&b!=t.length-1){h.lastIndex=k;var _=h.exec(e);if(!_)break;for(var j=_.index+(d?_[1].length:0),P=_.index+_[0].length,A=b,x=k,O=t.length;O>A&&(P>x||!t[A].type&&!t[A-1].greedy);++A)x+=t[A].length,j>=x&&(++b,k=x);if(t[b]instanceof s)continue;I=A-b,w=e.slice(k,x),_.index-=k}else{h.lastIndex=0;var _=h.exec(w),I=1}if(_){d&&(p=_[1]?_[1].length:0);var j=_.index+p,_=_[0].slice(p),P=j+_.length,N=w.slice(0,j),S=w.slice(P),C=[b,I];N&&(++b,k+=N.length,C.push(N));var E=new s(u,f?n.tokenize(_,f):_,y,_,m);if(C.push(E),S&&C.push(S),Array.prototype.splice.apply(t,C),1!=I&&n.matchGrammar(e,t,r,b,k,!0,u),i)break}else if(i)break}}}}},tokenize:function(e,t){var r=[e],a=t.rest;if(a){for(var l in a)t[l]=a[l];delete t.rest}return n.matchGrammar(e,r,t,0,0,!1),r},hooks:{all:{},add:function(e,t){var r=n.hooks.all;r[e]=r[e]||[],r[e].push(t)},run:function(e,t){var r=n.hooks.all[e];if(r&&r.length)for(var a,l=0;a=r[l++];)a(t)}}},r=n.Token=function(e,t,n,r,a){this.type=e,this.content=t,this.alias=n,this.length=0|(r||"").length,this.greedy=!!a};if(r.stringify=function(e,t,a){if("string"==typeof e)return e;if("Array"===n.util.type(e))return e.map(function(n){return r.stringify(n,t,e)}).join("");var l={type:e.type,content:r.stringify(e.content,t,a),tag:"span",classes:["token",e.type],attributes:{},language:t,parent:a};if(e.alias){var i="Array"===n.util.type(e.alias)?e.alias:[e.alias];Array.prototype.push.apply(l.classes,i)}n.hooks.run("wrap",l);var o=Object.keys(l.attributes).map(function(e){return e+'="'+(l.attributes[e]||"").replace(/"/g,""")+'"'}).join(" ");return"<"+l.tag+' class="'+l.classes.join(" ")+'"'+(o?" "+o:"")+">"+l.content+""},!_self.document)return _self.addEventListener?(n.disableWorkerMessageHandler||_self.addEventListener("message",function(e){var t=JSON.parse(e.data),r=t.language,a=t.code,l=t.immediateClose;_self.postMessage(n.highlight(a,n.languages[r],r)),l&&_self.close()},!1),_self.Prism):_self.Prism;var a=document.currentScript||[].slice.call(document.getElementsByTagName("script")).pop();return a&&(n.filename=a.src,n.manual||a.hasAttribute("data-manual")||("loading"!==document.readyState?window.requestAnimationFrame?window.requestAnimationFrame(n.highlightAll):window.setTimeout(n.highlightAll,16):document.addEventListener("DOMContentLoaded",n.highlightAll))),_self.Prism}();"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); 4 | Prism.languages.markup={comment://,prolog:/<\?[\s\S]+?\?>/,doctype://i,cdata://i,tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s+[^\s>\/=]+(?:=(?:("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|[^\s'">=]+))?)*\s*\/?>/i,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"attr-value":{pattern:/=(?:("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|[^\s'">=]+)/i,inside:{punctuation:[/^=/,{pattern:/(^|[^\\])["']/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:/&#?[\da-z]{1,8};/i},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.hooks.add("wrap",function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))}),Prism.languages.xml=Prism.languages.markup,Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup; 5 | Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/((?:\b(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,"boolean":/\b(?:true|false)\b/,"function":/[a-z0-9_]+(?=\()/i,number:/\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*|\/|~|\^|%/,punctuation:/[{}[\];(),.:]/}; 6 | Prism.languages.javascript=Prism.languages.extend("clike",{keyword:/\b(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/,number:/\b(?:0[xX][\dA-Fa-f]+|0[bB][01]+|0[oO][0-7]+|NaN|Infinity)\b|(?:\b\d+\.?\d*|\B\.\d+)(?:[Ee][+-]?\d+)?/,"function":/[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*\()/i,operator:/-[-=]?|\+[+=]?|!=?=?|<>?>?=?|=(?:==?|>)?|&[&=]?|\|[|=]?|\*\*?=?|\/=?|~|\^=?|%=?|\?|\.{3}/}),Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:/((?:^|[^$\w\xA0-\uFFFF."'\])\s])\s*)\/(\[[^\]\r\n]+]|\\.|[^\/\\\[\r\n])+\/[gimyu]{0,5}(?=\s*($|[\r\n,.;})\]]))/,lookbehind:!0,greedy:!0},"function-variable":{pattern:/[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*=\s*(?:function\b|(?:\([^()]*\)|[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)\s*=>))/i,alias:"function"},constant:/\b[A-Z][A-Z\d_]*\b/}),Prism.languages.insertBefore("javascript","string",{"template-string":{pattern:/`(?:\\[\s\S]|\${[^}]+}|[^\\`])*`/,greedy:!0,inside:{interpolation:{pattern:/\${[^}]+}/,inside:{"interpolation-punctuation":{pattern:/^\${|}$/,alias:"punctuation"},rest:null}},string:/[\s\S]+/}}}),Prism.languages.javascript["template-string"].inside.interpolation.inside.rest=Prism.languages.javascript,Prism.languages.markup&&Prism.languages.insertBefore("markup","tag",{script:{pattern:/()[\s\S]*?(?=<\/script>)/i,lookbehind:!0,inside:Prism.languages.javascript,alias:"language-javascript",greedy:!0}}),Prism.languages.js=Prism.languages.javascript; 7 | !function(t){var n=t.util.clone(t.languages.javascript);t.languages.jsx=t.languages.extend("markup",n),t.languages.jsx.tag.pattern=/<\/?(?:[\w.:-]+\s*(?:\s+(?:[\w.:-]+(?:=(?:("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|[^\s{'">=]+|\{(?:\{(?:\{[^}]*\}|[^{}])*\}|[^{}])+\}))?|\{\.{3}[a-z_$][\w$]*(?:\.[a-z_$][\w$]*)*\}))*\s*\/?)?>/i,t.languages.jsx.tag.inside.tag.pattern=/^<\/?[^\s>\/]*/i,t.languages.jsx.tag.inside["attr-value"].pattern=/=(?!\{)(?:("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|[^\s'">]+)/i,t.languages.insertBefore("inside","attr-name",{spread:{pattern:/\{\.{3}[a-z_$][\w$]*(?:\.[a-z_$][\w$]*)*\}/,inside:{punctuation:/\.{3}|[{}.]/,"attr-value":/\w+/}}},t.languages.jsx.tag),t.languages.insertBefore("inside","attr-value",{script:{pattern:/=(\{(?:\{(?:\{[^}]*\}|[^}])*\}|[^}])+\})/i,inside:{"script-punctuation":{pattern:/^=(?={)/,alias:"punctuation"},rest:t.languages.jsx},alias:"language-javascript"}},t.languages.jsx.tag);var e=function(t){return t?"string"==typeof t?t:"string"==typeof t.content?t.content:t.content.map(e).join(""):""},a=function(n){for(var s=[],g=0;g0&&s[s.length-1].tagName===e(o.content[0].content[1])&&s.pop():"/>"===o.content[o.content.length-1].content||s.push({tagName:e(o.content[0].content[1]),openedBraces:0}):s.length>0&&"punctuation"===o.type&&"{"===o.content?s[s.length-1].openedBraces++:s.length>0&&s[s.length-1].openedBraces>0&&"punctuation"===o.type&&"}"===o.content?s[s.length-1].openedBraces--:i=!0),(i||"string"==typeof o)&&s.length>0&&0===s[s.length-1].openedBraces){var p=e(o);g0&&("string"==typeof n[g-1]||"plain-text"===n[g-1].type)&&(p=e(n[g-1])+p,n.splice(g-1,1),g--),n[g]=new t.Token("plain-text",p,null,p)}o.content&&"string"!=typeof o.content&&a(o.content)}};t.hooks.add("after-tokenize",function(t){("jsx"===t.language||"tsx"===t.language)&&a(t.tokens)})}(Prism); 8 | -------------------------------------------------------------------------------- /examples/styles/examples.css: -------------------------------------------------------------------------------- 1 | .jumbotron { 2 | margin-bottom: 0; 3 | } 4 | 5 | code { 6 | border-radius: 3px; 7 | line-height: 1.8 !important; 8 | padding: 0 !important; 9 | margin: 0 !important; 10 | } 11 | 12 | pre { 13 | margin: 0 !important; 14 | padding: 1rem !important; 15 | } 16 | 17 | .option.option--success:hover { 18 | background-color: #28a745; 19 | color: white; 20 | } 21 | 22 | .option.option--danger:hover { 23 | background-color: #dc3545; 24 | color: white; 25 | } 26 | 27 | .option.option--primary:hover { 28 | background-color: #007bff; 29 | color: white; 30 | } 31 | 32 | .custom-option { 33 | margin-left: 10px; 34 | } 35 | 36 | .custom-option button { 37 | vertical-align: middle; 38 | } -------------------------------------------------------------------------------- /examples/styles/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.14.0 2 | https://prismjs.com/download.html#themes=prism-okaidia&languages=markup+clike+javascript+jsx */ 3 | /** 4 | * okaidia theme for JavaScript, CSS and HTML 5 | * Loosely based on Monokai textmate theme by http://www.monokai.nl/ 6 | * @author ocodia 7 | */ 8 | 9 | code[class*="language-"], 10 | pre[class*="language-"] { 11 | color: #f8f8f2; 12 | background: none; 13 | text-shadow: 0 1px rgba(0, 0, 0, 0.3); 14 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 15 | text-align: left; 16 | white-space: pre; 17 | word-spacing: normal; 18 | word-break: normal; 19 | word-wrap: normal; 20 | line-height: 1.5; 21 | 22 | -moz-tab-size: 4; 23 | -o-tab-size: 4; 24 | tab-size: 4; 25 | 26 | -webkit-hyphens: none; 27 | -moz-hyphens: none; 28 | -ms-hyphens: none; 29 | hyphens: none; 30 | } 31 | 32 | /* Code blocks */ 33 | pre[class*="language-"] { 34 | padding: 1em; 35 | margin: .5em 0; 36 | overflow: auto; 37 | border-radius: 0.3em; 38 | } 39 | 40 | :not(pre) > code[class*="language-"], 41 | pre[class*="language-"] { 42 | background: #272822; 43 | } 44 | 45 | /* Inline code */ 46 | :not(pre) > code[class*="language-"] { 47 | padding: .1em; 48 | border-radius: .3em; 49 | white-space: normal; 50 | } 51 | 52 | .token.comment, 53 | .token.prolog, 54 | .token.doctype, 55 | .token.cdata { 56 | color: slategray; 57 | } 58 | 59 | .token.punctuation { 60 | color: #f8f8f2; 61 | } 62 | 63 | .namespace { 64 | opacity: .7; 65 | } 66 | 67 | .token.property, 68 | .token.tag, 69 | .token.constant, 70 | .token.symbol, 71 | .token.deleted { 72 | color: #f92672; 73 | } 74 | 75 | .token.boolean, 76 | .token.number { 77 | color: #ae81ff; 78 | } 79 | 80 | .token.selector, 81 | .token.attr-name, 82 | .token.string, 83 | .token.char, 84 | .token.builtin, 85 | .token.inserted { 86 | color: #a6e22e; 87 | } 88 | 89 | .token.operator, 90 | .token.entity, 91 | .token.url, 92 | .language-css .token.string, 93 | .style .token.string, 94 | .token.variable { 95 | color: #f8f8f2; 96 | } 97 | 98 | .token.atrule, 99 | .token.attr-value, 100 | .token.function, 101 | .token.class-name { 102 | color: #e6db74; 103 | } 104 | 105 | .token.keyword { 106 | color: #66d9ef; 107 | } 108 | 109 | .token.regex, 110 | .token.important { 111 | color: #fd971f; 112 | } 113 | 114 | .token.important, 115 | .token.bold { 116 | font-weight: bold; 117 | } 118 | .token.italic { 119 | font-style: italic; 120 | } 121 | 122 | .token.entity { 123 | cursor: help; 124 | } 125 | 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-16-dropdown", 3 | "version": "0.3.1", 4 | "description": "A zero-dependency, lightweight and fully customizable dropdown (not select) for React.", 5 | "main": "dist/react-16-dropdown.js", 6 | "scripts": { 7 | "test": "nyc mocha test/specs/*.js*", 8 | "test-lint": "eslint src", 9 | "start": "webpack-dev-server", 10 | "build": "webpack", 11 | "build-docs": "NODE_ENV=production webpack" 12 | }, 13 | "author": "muchhalaankit@gmail.com", 14 | "homepage": "https://github.com/ankit-m/react-16-dropdown", 15 | "bugs": { 16 | "url": "https://github.com/ankit-m/react-16-dropdown/issues", 17 | "email": "muchhalaankit@gmail.com" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/ankit-m/react-16-dropdown.git" 22 | }, 23 | "keywords": [ 24 | "react", 25 | "dropdown" 26 | ], 27 | "license": "ISC", 28 | "peerDependencies": { 29 | "react": "16.4.0", 30 | "react-dom": "16.4.0" 31 | }, 32 | "devDependencies": { 33 | "babel-core": "6.26.0", 34 | "babel-loader": "7.1.2", 35 | "babel-preset-env": "1.6.1", 36 | "babel-preset-react": "6.24.1", 37 | "babel-register": "6.26.0", 38 | "chai": "4.1.2", 39 | "css-loader": "0.28.8", 40 | "enzyme": "^3.3.0", 41 | "enzyme-adapter-react-16": "^1.1.1", 42 | "eslint": "4.19.1", 43 | "eslint-config-airbnb": "16.1.0", 44 | "eslint-plugin-import": "2.12.0", 45 | "eslint-plugin-jsx-a11y": "6.0.3", 46 | "eslint-plugin-react": "7.9.1", 47 | "html-webpack-plugin": "2.30.1", 48 | "jsdom": "11.11.0", 49 | "mocha": "5.2.0", 50 | "nyc": "12.0.2", 51 | "react": "16.4.0", 52 | "react-dom": "16.4.0", 53 | "sinon": "6.0.0", 54 | "style-loader": "0.19.1", 55 | "webpack": "3.10.0", 56 | "webpack-dev-server": "2.11.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Dropdown.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import Menu from './Menu'; 4 | import Trigger from './Trigger'; 5 | import { getAbsoluteBoundingRect, optimizedResize } from './utils'; 6 | 7 | export default class Dropdown extends Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | this.state = { open: Boolean(props.open) }; 12 | 13 | this.menuRef = React.createRef(); 14 | this.triggerRef = React.createRef(); 15 | this.controlled = Object.prototype.hasOwnProperty.call(this.props, 'open'); 16 | 17 | this.handleTriggerClick = this.handleTriggerClick.bind(this); 18 | this.handleOptionClick = this.handleOptionClick.bind(this); 19 | this.handleTriggerKeyDown = this.handleTriggerKeyDown.bind(this); 20 | this.handleEscape = this.handleEscape.bind(this); 21 | this.closeMenu = this.closeMenu.bind(this); 22 | this.openMenu = this.openMenu.bind(this); 23 | this.handleClickOutside = this.handleClickOutside.bind(this); 24 | this.setTriggerRect = this.setTriggerRect.bind(this); 25 | this.focusTrigger = this.focusTrigger.bind(this); 26 | } 27 | 28 | componentDidMount() { 29 | this.setTriggerRect(); 30 | 31 | this.props.autoFocus && this.focusTrigger(); 32 | 33 | optimizedResize.add(this.setTriggerRect); 34 | } 35 | 36 | componentDidUpdate(prevProps, prevState) { 37 | if (this.controlled) { 38 | return; 39 | } 40 | 41 | if (this.state.open && !prevState.open) { 42 | typeof this.props.onOpen === 'function' && this.props.onOpen(); 43 | } 44 | 45 | if (this.state.open) { 46 | this.props.closeOnEscape && document.addEventListener('keyup', this.handleEscape); 47 | this.props.closeOnClickOutside && document.addEventListener('click', this.handleClickOutside); 48 | } else { 49 | this.props.closeOnEscape && document.removeEventListener('keyup', this.handleEscape); 50 | this.props.closeOnClickOutside && document.removeEventListener('click', this.handleClickOutside); 51 | } 52 | } 53 | 54 | componentWillUnmount() { 55 | document.removeEventListener('keyup', this.handleEscape); 56 | document.removeEventListener('click', this.handleClickOutside); 57 | } 58 | 59 | setTriggerRect() { 60 | if (!this.triggerRef.current) { 61 | return; 62 | } 63 | 64 | this.setState({ 65 | triggerBoundingRect: getAbsoluteBoundingRect(this.triggerRef.current), 66 | }); 67 | } 68 | 69 | // focus the custom component passed or renderer 70 | focusTrigger() { 71 | if (this.props.triggerComponent) { 72 | this.triggerRef.current.focus(); 73 | } else { 74 | this.triggerRef.current.firstChild.focus(); 75 | } 76 | } 77 | 78 | closeMenu(focus) { 79 | this.setState({ open: false }, () => { 80 | focus && this.focusTrigger(); 81 | }); 82 | } 83 | 84 | openMenu() { 85 | this.setState({ open: true }); 86 | } 87 | 88 | handleClickOutside(e) { 89 | if (!this.menuRef.current) { 90 | return; 91 | } 92 | 93 | if (!this.menuRef.current.contains(e.target)) { 94 | this.closeMenu(); 95 | } 96 | } 97 | 98 | handleEscape(e) { 99 | if (e.key === 'Escape') { 100 | this.closeMenu(true); 101 | } 102 | } 103 | 104 | handleTriggerClick() { 105 | // re-calculating the position of dropdown to remove scrolling side effects 106 | this.setTriggerRect(); 107 | 108 | typeof this.props.onTriggerClick === 'function' && this.props.onTriggerClick(); 109 | 110 | if (this.controlled) { 111 | return; 112 | } 113 | 114 | this.setState(prevState => ({ open: !prevState.open })); 115 | } 116 | 117 | handleTriggerKeyDown(e) { 118 | typeof this.props.onTriggerKeyDown === 'function' && this.props.onTriggerKeyDown(); 119 | 120 | if (this.controlled) { 121 | return; 122 | } 123 | 124 | if (e.key === 'ArrowDown') { 125 | this.openMenu(); 126 | 127 | e.preventDefault(); 128 | } 129 | } 130 | 131 | handleOptionClick(val) { 132 | typeof this.props.onClick === 'function' && this.props.onClick(val); 133 | 134 | !this.controlled && this.props.closeOnOptionClick && this.closeMenu(true); 135 | } 136 | 137 | render() { 138 | const TriggerElement = this.props.triggerComponent || Trigger; 139 | const open = this.controlled ? this.props.open : this.state.open; 140 | const classes = 'react-16-dropdown' + 141 | (this.props.className ? ` ${this.props.className}` : ''); 142 | 143 | return ( 144 |
148 | 156 | 157 | {open && this.state.triggerBoundingRect && 158 | 165 | } 166 |
167 | ); 168 | } 169 | } 170 | 171 | Dropdown.defaultProps = { 172 | autoFocus: false, 173 | triggerLabel: 'Open menu', 174 | closeOnEscape: true, 175 | closeOnClickOutside: true, 176 | closeOnOptionClick: false, 177 | disabled: false, 178 | align: 'left', 179 | options: [], 180 | sections: [], 181 | }; 182 | -------------------------------------------------------------------------------- /src/Menu.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import Option from './Option'; 5 | 6 | /** 7 | * Default menu renderer 8 | * 9 | * @param {Object} props - React props 10 | * @param {ReactElement} props.children - Options to render 11 | * @returns {ReactElement} menu 12 | */ 13 | function MenuRenderer(props) { 14 | return props.children; 15 | } 16 | 17 | /** 18 | * Default component for menu section 19 | * 20 | * @param {Object} props - React props 21 | * @param {String} props.title - Section title 22 | * @param {ReactElement} props.children - Options in the section 23 | * @returns {ReactElement} menu section 24 | */ 25 | function MenuSectionRenderer(props) { 26 | const className = 'menu-section' + 27 | (props.className ? ` ${props.className}` : ''); 28 | 29 | return ( 30 |
31 |
{props.title}
32 |
33 | {props.children} 34 |
35 |
36 | ); 37 | } 38 | 39 | /** 40 | * Default menu component 41 | * 42 | * @param {Object} props - React props 43 | * @param {ReactElement} props.renderer - Menu renderer 44 | * @param {ReactRef} props.menuRef - Ref for the menu component 45 | * @param {Object} props.style - Inline styles for menu 46 | * @param {Function} props.onKeyDown - Handler for keyboard events 47 | * @param {ReactElement} props.children - Option elements 48 | */ 49 | function Menu(props) { 50 | const Renderer = props.renderer; 51 | 52 | return ( 53 |
61 | {props.children} 62 |
63 | ); 64 | } 65 | 66 | /** 67 | * Portal for the menu 68 | * 69 | * @help https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Using_tabindex 70 | */ 71 | export default class MenuPortal extends Component { 72 | constructor(props) { 73 | super(props); 74 | 75 | this.state = { focused: -1 }; 76 | 77 | this.optionRefs = {}; 78 | this.el = document.createElement('div'); 79 | this.el.classList.add('react-16-dropdown-portal'); 80 | props.portalClassName && this.el.classList.add(props.portalClassName); 81 | 82 | this.handleKeyDown = this.handleKeyDown.bind(this); 83 | this.getAlignment = this.getAlignment.bind(this); 84 | this.setOptionRefs = this.setOptionRefs.bind(this); 85 | this.getOptions = this.getOptions.bind(this); 86 | this.getOptionElements = this.getOptionElements.bind(this); 87 | } 88 | 89 | componentDidMount() { 90 | document.querySelector(this.props.menuPortalTarget).appendChild(this.el); 91 | 92 | this.props.controlled && this.props.focused && this.optionRefs[this.props.focused].focus(); 93 | 94 | if (!this.props.controlled || this.props.autoFocusMenu) { 95 | this.props.menuRef.current.focus(); 96 | } 97 | } 98 | 99 | componentDidUpdate() { 100 | const options = this.getOptions(); 101 | 102 | let key; 103 | 104 | if (!this.props.controlled) { 105 | const selected = options[this.state.focused]; 106 | 107 | key = selected && selected.value; 108 | } else { 109 | key = this.props.focused; 110 | } 111 | 112 | key && this.optionRefs[key].focus(); 113 | } 114 | 115 | componentWillUnmount() { 116 | document.querySelector(this.props.menuPortalTarget).removeChild(this.el); 117 | } 118 | 119 | getAlignment() { 120 | // @todo allow other alignments 121 | const boundingRect = this.props.triggerBoundingRect; 122 | const top = boundingRect.top + boundingRect.height; 123 | 124 | if (this.props.align === 'left') { 125 | return { 126 | top, 127 | left: boundingRect.left, 128 | }; 129 | } 130 | 131 | if (this.props.align === 'right') { 132 | return { 133 | top, 134 | right: window.innerWidth - boundingRect.right - window.scrollX, 135 | }; 136 | } 137 | 138 | return {}; 139 | } 140 | 141 | getOptions() { 142 | const { options, sections } = this.props; 143 | 144 | if (sections.length) { 145 | return sections.reduce((res, sec) => res.concat(sec.options), []); 146 | } 147 | 148 | return options; 149 | } 150 | 151 | getOptionElements() { 152 | const { sections } = this.props; 153 | const options = this.getOptions(); 154 | const OptionElement = this.props.optionComponent; 155 | const SectionRenderer = this.props.menuSectionRenderer; 156 | const focused = this.props.controlled ? 157 | options.map(o => o.value).indexOf(this.props.focused) : 158 | this.state.focused; 159 | 160 | if (sections.length) { 161 | return sections.map((sec, i) => ( 162 | 166 | {sec.options.map((option, j) => ( 167 | this.setOptionRefs(node, option.value)} 173 | renderer={this.props.optionRenderer} 174 | onClick={() => { this.props.onClick(option); }} 175 | /> 176 | ))} 177 | 178 | )); 179 | } 180 | 181 | return options.map((option, i) => ( 182 | this.setOptionRefs(node, option.value)} 188 | renderer={this.props.optionRenderer} 189 | onClick={() => { this.props.onClick(option); }} 190 | /> 191 | )); 192 | } 193 | 194 | setOptionRefs(node, key) { 195 | node && (this.optionRefs[key] = node); 196 | } 197 | 198 | handleKeyDown(e) { 199 | typeof this.props.onMenuKeyDown === 'function' && this.props.onMenuKeyDown(e); 200 | 201 | if (this.props.controlled) { 202 | return; 203 | } 204 | 205 | const options = this.getOptions(); 206 | const maxFocus = options.length - 1; 207 | const focusedOption = options[this.state.focused]; 208 | 209 | // NOTE: This method is called when the menu is 210 | // opened with the keyboard. This case handles it 211 | if (e.key === 'Enter' && focusedOption && !focusedOption.disabled) { 212 | this.props.onClick(focusedOption.value); 213 | } else if (e.key === 'ArrowDown') { 214 | this.setState(prevState => ({ 215 | focused: prevState.focused < maxFocus ? prevState.focused + 1 : maxFocus, 216 | })); 217 | } else if (e.key === 'ArrowUp') { 218 | this.setState(prevState => ({ 219 | focused: prevState.focused > 0 ? prevState.focused - 1 : 0, 220 | })); 221 | } 222 | 223 | e.preventDefault(); 224 | } 225 | 226 | render() { 227 | const MenuElement = this.props.menuComponent; 228 | 229 | const menu = ( 230 | 236 | {this.getOptionElements()} 237 | 238 | ); 239 | 240 | return ReactDOM.createPortal(menu, this.el); 241 | } 242 | } 243 | 244 | MenuPortal.defaultProps = { 245 | menuComponent: Menu, 246 | optionComponent: Option, 247 | menuRenderer: MenuRenderer, 248 | menuSectionRenderer: MenuSectionRenderer, 249 | menuPortalTarget: 'body', 250 | }; 251 | -------------------------------------------------------------------------------- /src/Option.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Default renderer for option. Renders a div with 5 | * child as label. 6 | * 7 | * @param {Object} props - React props 8 | * @param {String} props.className - Custom class 9 | * @param {Boolean} props.focused - Is focused? 10 | * @param {String|ReactElement} props.label - Option label 11 | * @returns {ReactElement} 12 | */ 13 | export function OptionRenderer(props) { 14 | const classes = 'option' + 15 | (props.focused ? ' focused' : '') + 16 | (props.disabled ? ' disabled' : '') + 17 | (props.className ? ` ${props.className}` : ''); 18 | 19 | return ( 20 |
21 | {props.label} 22 |
23 | ); 24 | } 25 | 26 | /** 27 | * Default option component. It renders a div with 28 | * renderer as a child. 29 | * 30 | * @param {Object} props - React props 31 | * @param {Boolean} props.focused - Is option focused? 32 | * @param {ReactRef} props.optionRef - React ref for option 33 | * @param {Function} props.onClick - Click handler 34 | * @param {Object} props.data - Option data 35 | * @param {String} props.className - Custom class 36 | * @returns {ReactElement} 37 | */ 38 | export default function Option(props) { 39 | const Renderer = props.renderer; 40 | 41 | return ( 42 |
49 | 54 |
55 | ); 56 | } 57 | 58 | Option.defaultProps = { 59 | renderer: OptionRenderer, 60 | data: {}, 61 | }; 62 | -------------------------------------------------------------------------------- /src/Trigger.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Default trigger renderer - Displays a plain button 5 | * with label 6 | * 7 | * @param {Object} props - React props 8 | * @param {Boolean} props.disabled - Trigger disabled 9 | * @param {String|ReactElement} props.label - Trigger display label 10 | */ 11 | export function TriggerRenderer(props) { 12 | return ( 13 | 19 | ); 20 | } 21 | 22 | /** 23 | * Default trigger component - Renders a div with 24 | * all the handlers 25 | * 26 | * @param {Object} props - React props 27 | * @param {ReactElement} [props.renderer] - Custom trigger renderer 28 | * @param {ReactRef} props.triggerRef - React ref for trigger 29 | * @param {Function} props.onClick - Click handler 30 | * @param {Function} props.onKeyDown - Key down handler 31 | * @param {Boolean} props.disabled - Trigger disabled 32 | * @param {String|ReactElement} props.label - Trigger display label 33 | */ 34 | export default function Trigger(props) { 35 | const Renderer = props.renderer || TriggerRenderer; 36 | 37 | return ( 38 |
45 | 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | .react-16-dropdown, 2 | .trigger { 3 | display: inline-block; 4 | } 5 | 6 | .trigger-renderer { 7 | border-radius: 3px; 8 | line-height: 1.5; 9 | padding: .375rem .75rem; 10 | cursor: pointer; 11 | background: #ffffff; 12 | font-size: 1rem; 13 | border: 1px solid #dcdcdc; 14 | } 15 | 16 | .trigger-renderer:hover { 17 | background: #f0f0f0; 18 | } 19 | 20 | /*********************** Menu styles begin ***********************/ 21 | .menu { 22 | position: absolute; 23 | background: white; 24 | border: 1px solid #dcdcdc; 25 | border-radius: 3px; 26 | margin-top: 8px; 27 | min-width: 300px; 28 | max-width: 420px; 29 | box-shadow : 0 0 10px -5px #000000; 30 | } 31 | 32 | .menu-section { 33 | border-bottom: 1px solid #dcdcdc; 34 | border-top: 1px solid #dcdcdc; 35 | } 36 | 37 | .menu-section:first-of-type { 38 | border-top: 0; 39 | } 40 | 41 | .menu-section:last-of-type { 42 | border-bottom: 0; 43 | } 44 | 45 | .menu-section + .menu-section { 46 | border-top: 0; 47 | } 48 | 49 | .menu-section__title { 50 | font-size: 1rem; 51 | padding: .5rem; 52 | font-weight: bold; 53 | } 54 | 55 | .menu-section-title:empty { 56 | padding: 0; 57 | } 58 | 59 | .menu-section-title:empty + .menu-section { 60 | border-top: 0; 61 | } 62 | 63 | .menu:focus { 64 | /* outline: none */ 65 | } 66 | /*********************** Menu styles end ***********************/ 67 | 68 | .option { 69 | min-height: 24px; 70 | padding: 8px 16px; 71 | } 72 | 73 | .option.focused, 74 | .option:hover { 75 | background: #f0f0f0; 76 | cursor: pointer; 77 | } 78 | 79 | .option:focus { 80 | border: 10px solid pink; 81 | } 82 | 83 | .option.disabled { 84 | opacity: 0.5; 85 | background: #f0f0f0; 86 | } 87 | 88 | .option.disabled:hover { 89 | cursor: no-drop; 90 | } -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const optimizedResize = (function () { 2 | const callbacks = []; 3 | let running = false; 4 | 5 | // run the actual callbacks 6 | function runCallbacks() { 7 | callbacks.forEach((callback) => { 8 | callback(); 9 | }); 10 | 11 | running = false; 12 | } 13 | 14 | // fired on resize event 15 | function resize() { 16 | if (!running) { 17 | running = true; 18 | 19 | if (window.requestAnimationFrame) { 20 | window.requestAnimationFrame(runCallbacks); 21 | } else { 22 | setTimeout(runCallbacks, 66); 23 | } 24 | } 25 | } 26 | 27 | // adds callback to loop 28 | function addCallback(callback) { 29 | if (callback) { 30 | callbacks.push(callback); 31 | } 32 | } 33 | 34 | return { 35 | // public method to add additional callback 36 | add(callback) { 37 | if (!callbacks.length) { 38 | window.addEventListener('resize', resize); 39 | } 40 | addCallback(callback); 41 | }, 42 | }; 43 | }()); 44 | 45 | export function getAbsoluteBoundingRect(el) { 46 | const clientRect = el.getBoundingClientRect(); 47 | const rect = {}; 48 | 49 | rect.left = window.scrollX + clientRect.left; 50 | rect.top = window.scrollY + clientRect.top; 51 | rect.right = clientRect.right; 52 | rect.bottom = clientRect.bottom; 53 | rect.height = clientRect.height; 54 | 55 | return rect; 56 | } 57 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "prefer-arrow-callback": "off", 7 | "no-unused-expressions": "off" 8 | } 9 | } -------------------------------------------------------------------------------- /test/.setup.js: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom'; 2 | import { expect } from 'chai'; 3 | const jsdom = new JSDOM(''); 4 | const { window } = jsdom; 5 | 6 | function copyProps(src, target) { 7 | const props = Object.getOwnPropertyNames(src) 8 | .filter(prop => typeof target[prop] === 'undefined') 9 | .map(prop => Object.getOwnPropertyDescriptor(src, prop)); 10 | Object.defineProperties(target, props); 11 | } 12 | 13 | global.expect = expect; 14 | global.window = window; 15 | global.document = window.document; 16 | global.navigator = { 17 | userAgent: 'node.js', 18 | }; 19 | copyProps(window, global); 20 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | 2 | --require babel-register 3 | --require test/.setup.js -------------------------------------------------------------------------------- /test/specs/Option.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { expect } from 'chai'; 3 | import { mount, shallow } from 'enzyme'; 4 | import { spy } from 'sinon'; 5 | 6 | import Option, { OptionRenderer } from '../../src/Option'; 7 | 8 | describe('', function () { 9 | it('should mount without errors', function () { 10 | const wrapper = mount(); 11 | 12 | expect(wrapper.find('div')).to.have.lengthOf(1); 13 | wrapper.unmount(); 14 | }); 15 | 16 | it('should unmount without errors', function () { 17 | const wrapper = mount(); 18 | 19 | wrapper.unmount(); 20 | expect(wrapper.find('div')).to.have.lengthOf(0); 21 | }); 22 | 23 | it('should render a div with \'option\' className', function () { 24 | const wrapper = shallow(); 25 | 26 | expect(wrapper.find('div.option')).to.have.lengthOf(1); 27 | }); 28 | 29 | it('should append focused class if the focused prop is true', function () { 30 | const wrapper = shallow(); 31 | 32 | expect(wrapper.find('div.focused')).to.have.lengthOf(1); 33 | }); 34 | 35 | it('should append custom className', function () { 36 | const wrapper = shallow(); 37 | 38 | expect(wrapper.find('div.test')).to.have.lengthOf(1); 39 | }); 40 | 41 | it('should render the label', function () { 42 | const wrapper = shallow(); 43 | 44 | expect(wrapper.text()).to.equal('label'); 45 | }); 46 | }); 47 | 48 | describe('