├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bower.json ├── dist ├── react-menu.js └── react-menu.min.js ├── eslint.json ├── examples └── basic │ ├── app.css │ ├── app.js │ └── index.html ├── karma.conf.js ├── lib ├── components │ ├── Menu.js │ ├── MenuOption.js │ ├── MenuOptions.js │ └── MenuTrigger.js ├── helpers │ ├── injectCSS.js │ └── uuid.js ├── index.js └── mixins │ └── buildClassName.js ├── package.json ├── scripts ├── build ├── dev-examples ├── preview-release ├── release └── test ├── specs ├── Menu.spec.js ├── buildClassName.spec.js ├── helper.js └── main.js ├── webpack.config.js └── webpack.examples-config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | examples/**/*-bundle.js -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | CONTRIBUTING.md 2 | bower.json 3 | examples 4 | karma.conf.js 5 | script 6 | specs 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v0.0.10 - Tue, 16 Aug 2016 15:36:53 GMT 2 | --------------------------------------- 3 | 4 | - 5 | 6 | 7 | v0.0.9 - Mon, 15 Aug 2016 20:11:02 GMT 8 | -------------------------------------- 9 | 10 | - 11 | 12 | 13 | v0.0.8 - Thu, 04 Aug 2016 15:27:58 GMT 14 | -------------------------------------- 15 | 16 | - 17 | 18 | 19 | v0.0.7 - Wed, 08 Jul 2015 15:16:44 GMT 20 | -------------------------------------- 21 | 22 | - [3487aad](../../commit/3487aad) [added] bump to react 0.13 23 | 24 | 25 | v0.0.6 - Sat, 16 May 2015 18:38:13 GMT 26 | -------------------------------------- 27 | 28 | - 29 | 30 | 31 | v0.0.5 - Sat, 29 Nov 2014 18:42:49 GMT 32 | -------------------------------------- 33 | 34 | - [d61cbcf](../../commit/d61cbcf) [fixed] typo in docs regarding css class names 35 | - [7e5fa2a](../../commit/7e5fa2a) [fixed] Accessibility issues closes #2 36 | - [7e91a17](../../commit/7e91a17) [added] smart css defaults for MenuOptions placement 37 | 38 | 39 | v0.0.4 - Wed, 15 Oct 2014 17:53:32 GMT 40 | -------------------------------------- 41 | 42 | - [022b8a8](../../commit/022b8a8) [fixed] issue where menu items not positioned absolute 43 | - [94b0dcf](../../commit/94b0dcf) [fixed] issue where menu always steals focus 44 | - [3fd228c](../../commit/3fd228c) [added] MenuOption cursor pointer in default style 45 | - [80cc592](../../commit/80cc592) [added] link to gh-pages example 46 | 47 | 48 | v0.0.3 - Sat, 11 Oct 2014 20:51:58 GMT 49 | -------------------------------------- 50 | 51 | - [984104a](../../commit/984104a) [added] basic examples in README 52 | - [5be176c](../../commit/5be176c) [fixed] issue where components not exposed 53 | 54 | 55 | v0.0.2 - Sat, 11 Oct 2014 20:11:06 GMT 56 | -------------------------------------- 57 | 58 | - 59 | 60 | 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Commit Subjects 2 | 3 | If your patch **changes the API or fixes a bug** please use one of the 4 | following prefixes in your commit subject: 5 | 6 | - `[fixed] ...` 7 | - `[changed] ...` 8 | - `[added] ...` 9 | - `[removed] ...` 10 | 11 | That ensures the subject line of your commit makes it into the 12 | auto-generated changelog. Do not use these tags if your change doesn't 13 | fix a bug and doesn't change the public API. 14 | 15 | Commits with changed, added, or removed, must be reviewed by another 16 | collaborator. 17 | 18 | #### When using `[changed]` or `[removed]`... 19 | 20 | Please include an upgrade path with example code in the commit message. 21 | If it doesn't make sense to do this, then it doesn't make sense to use 22 | `[changed]` or `[removed]` :) 23 | 24 | ### Docs 25 | 26 | Please update the README with any API changes, the code and docs should 27 | always be in sync. 28 | 29 | ### Development 30 | 31 | - `scripts/test` will fire up a karma runner and watch for changes in the 32 | specs directory. 33 | - `npm test` will do the same but doesn't watch, just runs the tests. 34 | - `scripts/build-examples` does exactly that. 35 | 36 | ### Build 37 | 38 | Please do not include the output of `scripts/build` in your commits, we 39 | only do this when we release. (Also, you probably don't need to build 40 | anyway unless you are fixing something around our global build.) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Jason Madsen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-menu 2 | 3 | An accessible menu component built for React.JS 4 | 5 | See example at [http://instructure-react.github.io/react-menu/](http://instructure-react.github.io/react-menu/) 6 | 7 | ## Basic Usage 8 | 9 | ```html 10 | var React = require('react'); 11 | var ReactDOM = require('react-dom'); 12 | 13 | var Menu = require('react-menu'); 14 | var MenuTrigger = Menu.MenuTrigger; 15 | var MenuOptions = Menu.MenuOptions; 16 | var MenuOption = Menu.MenuOption; 17 | 18 | var App = React.createClass({ 19 | 20 | render: function() { 21 | return ( 22 | 23 | 24 | ⚙ 25 | 26 | 27 | 28 | 29 | 1st Option 30 | 31 | 32 | 33 | 2nd Option 34 | 35 | 36 |
37 | non-selectable item 38 |
39 | 40 | 41 | disabled option 42 | 43 | 44 |
45 |
46 | ); 47 | } 48 | }); 49 | 50 | ReactDOM.render(, document.body); 51 | 52 | ``` 53 | 54 | For a working example see the `examples/basic` example 55 | 56 | ## Styles 57 | 58 | Bring in default styles by calling `injectCSS` on the `Menu` component. 59 | 60 | ```javascript 61 | var Menu = require('react-menu'); 62 | 63 | Menu.injectCSS(); 64 | ``` 65 | 66 | Default styles will be added to the top of the head, and thus any styles you 67 | write will override any of the defaults. 68 | 69 | The following class names are used / available for modification in your own stylsheets: 70 | 71 | ``` 72 | .Menu 73 | .Menu__MenuTrigger 74 | .Menu__MenuOptions 75 | .Menu__MenuOption 76 | .Menu__MenuOptions--vertical-bottom 77 | .Menu__MenuOptions--vertical-top 78 | .Menu__MenuOptions--horizontal-right 79 | .Menu__MenuOptions--horizontal-left 80 | ``` 81 | 82 | The last four class names control the placement of menu options when the menu 83 | would otherwise bleed off the screen. See `/lib/helpers/injectCSS.js` for 84 | defaults. The `.Menu__MenuOptions` element will always have a vertical and 85 | horizontal modifier. 86 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-menu", 3 | "version": "0.0.10", 4 | "homepage": "https://github.com/knomedia/react-menu", 5 | "authors": [ 6 | "Jason Madsen" 7 | ], 8 | "description": "Accessible menu component for React.JS", 9 | "main": "dist/react-menu.js", 10 | "keywords": [ 11 | "react", 12 | "menu", 13 | "dropdown" 14 | ], 15 | "license": "MIT", 16 | "ignore": [ 17 | "**/.*", 18 | "node_modules", 19 | "bower_components", 20 | "specs", 21 | "modules", 22 | "examples", 23 | "script", 24 | "CONTRIBUTING.md", 25 | "karma.conf.js", 26 | "package.json" 27 | ] 28 | } -------------------------------------------------------------------------------- /eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true 5 | }, 6 | "rules": { 7 | "quotes": 0, 8 | "no-comma-dangle": 2, 9 | "no-underscore-dangle": 0, 10 | "curly": 0, 11 | "strict": 0, 12 | "no-use-before-define": 0, 13 | "no-cond-assign": 0, 14 | "consistent-return": 0, 15 | "new-cap": 0, 16 | "no-unused-vars": 0 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/basic/app.css: -------------------------------------------------------------------------------- 1 | .Menu { 2 | width: 37px; 3 | } 4 | 5 | .Menu__MenuOptions { 6 | width: 150px; 7 | } 8 | 9 | .Menu__MenuTrigger { 10 | text-align: right; 11 | font-size: 1.8em; 12 | padding-right: 10px; 13 | } 14 | 15 | .spacer { 16 | height: 5px; 17 | border-bottom: 1px solid #ccc; 18 | } 19 | -------------------------------------------------------------------------------- /examples/basic/app.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var ReactDOM = require('react-dom'); 3 | var Menu = require('../../lib/index'); 4 | var MenuTrigger = require('../../lib/components/MenuTrigger'); 5 | var MenuOptions = require('../../lib/components/MenuOptions'); 6 | var MenuOption = require('../../lib/components/MenuOption'); 7 | 8 | 9 | Menu.injectCSS(); 10 | 11 | 12 | var App = React.createClass({ 13 | 14 | handleSecondOption: function() { 15 | alert('SECOND OPTION CLICKED'); 16 | }, 17 | 18 | handleDisabledSelect: function() { 19 | alert('this one is disabled'); 20 | }, 21 | 22 | render: function() { 23 | return ( 24 |
25 | 26 | 27 | ⚙ 28 | 29 | 30 | 31 | 32 | 33 | 1st Option 34 | 35 | 36 | 37 | 2nd Option 38 | 39 | 40 |
41 |
42 | 43 | 44 | 3rd Option 45 | 46 | 47 | 48 | 4th Option 49 | 50 | 51 | 52 | disabled option 53 | 54 | 55 |
56 |
57 | 58 |

react-menu has keyboard and screen reader support.

59 | 60 |
61 | ) 62 | } 63 | 64 | }); 65 | 66 | 67 | ReactDOM.render(, document.getElementById('example')); 68 | -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | Basic react-menu example 3 | 4 | 5 | 6 | 7 |
8 |

react-menu

9 |

an accessible React menu component

10 |
11 |
12 | Fork me on GitHub 13 | 14 | 15 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var assign = require("lodash.assign"); 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | 6 | basePath: '', 7 | 8 | frameworks: ['mocha'], 9 | 10 | files: [ 11 | 'specs/main.js' 12 | ], 13 | 14 | exclude: [], 15 | 16 | preprocessors: { 17 | 'specs/main.js': ['webpack'] 18 | }, 19 | 20 | webpack: assign(require('./webpack.config.js'), { 21 | debug: true, 22 | devtool: 'eval' 23 | }), 24 | 25 | webpackMiddleware: { 26 | noInfo: true 27 | }, 28 | 29 | reporters: ['progress'], 30 | 31 | port: 9876, 32 | 33 | colors: true, 34 | 35 | logLevel: config.LOG_INFO, 36 | 37 | autoWatch: true, 38 | 39 | browsers: ['Chrome'], 40 | 41 | captureTimeout: 60000, 42 | 43 | singleRun: false 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /lib/components/Menu.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var findDOMNode = require('react-dom').findDOMNode; 3 | 4 | var MenuTrigger = require('./MenuTrigger'); 5 | var MenuOptions = require('./MenuOptions'); 6 | var MenuOption = require('./MenuOption'); 7 | var uuid = require('../helpers/uuid'); 8 | var injectCSS = require('../helpers/injectCSS'); 9 | var buildClassName = require('../mixins/buildClassName'); 10 | 11 | var Menu = module.exports = React.createClass({ 12 | 13 | displayName: 'Menu', 14 | 15 | statics: { 16 | injectCSS: injectCSS 17 | }, 18 | 19 | mixins: [buildClassName], 20 | 21 | propTypes: { 22 | keepOpenOnSelect: React.PropTypes.bool, 23 | preferredHorizontal: React.PropTypes.oneOf(['left', 'right']), 24 | preferredVertical: React.PropTypes.oneOf(['top', 'bottom']) 25 | }, 26 | 27 | childContextTypes: { 28 | id: React.PropTypes.string, 29 | active: React.PropTypes.bool 30 | }, 31 | 32 | getChildContext: function () { 33 | return { 34 | id: this.state.id, 35 | active: this.state.active 36 | }; 37 | }, 38 | 39 | getDefaultProps: function() { 40 | return { 41 | preferredHorizontal: 'right', 42 | preferredVertical: 'bottom' 43 | }; 44 | }, 45 | 46 | getInitialState: function() { 47 | return { 48 | id: uuid(), 49 | active: false, 50 | selectedIndex: 0, 51 | horizontalPlacement: this.props.preferredHorizontal, 52 | verticalPlacement: this.props.preferredVertical 53 | }; 54 | }, 55 | 56 | onSelectionMade: function() { 57 | if (!this.props.keepOpenOnSelect) { 58 | this.closeMenu(this.focusTrigger); 59 | } 60 | }, 61 | 62 | closeMenu: function(cb) { 63 | if (cb) { 64 | this.setState({active: false}, cb); 65 | } else { 66 | this.setState({active: false}); 67 | } 68 | }, 69 | 70 | focusTrigger: function() { 71 | findDOMNode(this.refs.trigger).focus(); 72 | }, 73 | 74 | handleBlur: function(e) { 75 | // give next element a tick to take focus 76 | setTimeout(function() { 77 | if (!this.isMounted()) { 78 | return; 79 | } 80 | if (!findDOMNode(this).contains(document.activeElement) && this.state.active){ 81 | this.closeMenu(); 82 | } 83 | }.bind(this), 1); 84 | }, 85 | 86 | handleTriggerToggle: function() { 87 | this.setState({active: !this.state.active}, this.afterTriggerToggle); 88 | }, 89 | 90 | afterTriggerToggle: function() { 91 | if (this.state.active) { 92 | this.refs.options.focusOption(0); 93 | this.updatePositioning(); 94 | } 95 | }, 96 | 97 | updatePositioning: function() { 98 | var triggerRect = findDOMNode(this.refs.trigger).getBoundingClientRect(); 99 | var optionsRect = findDOMNode(this.refs.options).getBoundingClientRect(); 100 | var positionState = { 101 | horizontalPlacement: this.props.preferredHorizontal, 102 | verticalPlacement: this.props.preferredVertical 103 | }; 104 | // Only update preferred placement positions if necessary to keep menu from 105 | // appearing off-screen. 106 | if (triggerRect.left + optionsRect.width > window.innerWidth) { 107 | positionState.horizontalPlacement = 'left'; 108 | } else if (optionsRect.left < 0) { 109 | positionState.horizontalPlacement = 'right'; 110 | } 111 | if (triggerRect.bottom + optionsRect.height > window.innerHeight) { 112 | positionState.verticalPlacement = 'top'; 113 | } else if (optionsRect.top < 0) { 114 | positionState.verticalPlacement = 'bottom'; 115 | } 116 | this.setState(positionState); 117 | }, 118 | 119 | handleKeys: function(e) { 120 | if (e.key === 'Escape') { 121 | this.closeMenu(this.focusTrigger); 122 | } 123 | }, 124 | 125 | verifyTwoChildren: function() { 126 | var ok = (React.Children.count(this.props.children) === 2); 127 | if (!ok) 128 | throw 'react-menu can only take two children, a MenuTrigger, and a MenuOptions'; 129 | return ok; 130 | }, 131 | 132 | renderTrigger: function() { 133 | var trigger; 134 | if(this.verifyTwoChildren()) { 135 | React.Children.forEach(this.props.children, function(child){ 136 | if (child.type === MenuTrigger) { 137 | trigger = React.cloneElement(child, { 138 | ref: 'trigger', 139 | onToggleActive: this.handleTriggerToggle 140 | }); 141 | } 142 | }.bind(this)); 143 | } 144 | return trigger; 145 | }, 146 | 147 | renderMenuOptions: function() { 148 | var options; 149 | if(this.verifyTwoChildren()) { 150 | React.Children.forEach(this.props.children, function(child){ 151 | if (child.type === MenuOptions) { 152 | options = React.cloneElement(child, { 153 | ref: 'options', 154 | horizontalPlacement: this.state.horizontalPlacement, 155 | verticalPlacement: this.state.verticalPlacement, 156 | onSelectionMade: this.onSelectionMade 157 | }); 158 | } 159 | }.bind(this)); 160 | } 161 | return options; 162 | }, 163 | 164 | 165 | render: function() { 166 | return ( 167 |
172 | {this.renderTrigger()} 173 | {this.renderMenuOptions()} 174 |
175 | ) 176 | } 177 | 178 | }); 179 | -------------------------------------------------------------------------------- /lib/components/MenuOption.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var buildClassName = require('../mixins/buildClassName'); 3 | 4 | var MenuOption = module.exports = React.createClass({ 5 | 6 | propTypes: { 7 | active: React.PropTypes.bool, 8 | onSelect: React.PropTypes.func, 9 | onDisabledSelect: React.PropTypes.func, 10 | disabled: React.PropTypes.bool, 11 | role: React.PropTypes.string 12 | }, 13 | 14 | getDefaultProps: function() { 15 | return { 16 | role: 'menuitem' 17 | }; 18 | }, 19 | 20 | mixins: [buildClassName], 21 | 22 | notifyDisabledSelect: function() { 23 | if (this.props.onDisabledSelect) { 24 | this.props.onDisabledSelect(); 25 | } 26 | }, 27 | 28 | onSelect: function() { 29 | if (this.props.disabled) { 30 | this.notifyDisabledSelect(); 31 | //early return if disabled 32 | return; 33 | } 34 | if (this.props.onSelect) { 35 | this.props.onSelect(); 36 | } 37 | this.props._internalSelect(); 38 | }, 39 | 40 | handleKeyUp: function(e) { 41 | if (e.key === ' ') { 42 | this.onSelect(); 43 | } 44 | }, 45 | 46 | handleKeyDown: function(e) { 47 | e.preventDefault(); 48 | if (e.key === 'Enter') { 49 | this.onSelect(); 50 | } 51 | }, 52 | 53 | handleClick: function() { 54 | this.onSelect(); 55 | }, 56 | 57 | handleHover: function() { 58 | this.props._internalFocus(this.props.index); 59 | }, 60 | 61 | buildName: function() { 62 | var name = this.buildClassName('Menu__MenuOption'); 63 | if (this.props.active){ 64 | name += ' Menu__MenuOption--active'; 65 | } 66 | if (this.props.disabled) { 67 | name += ' Menu__MenuOption--disabled'; 68 | } 69 | return name; 70 | }, 71 | 72 | render: function() { 73 | var { 74 | active, onSelect, onDisabledSelect, disabled, role, children, ...otherProps 75 | } = this.props; 76 | return ( 77 |
88 | {children} 89 |
90 | ) 91 | } 92 | 93 | }); 94 | -------------------------------------------------------------------------------- /lib/components/MenuOptions.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var MenuOption = require('./MenuOption'); 3 | var buildClassName = require('../mixins/buildClassName'); 4 | var findDOMNode = require('react-dom').findDOMNode; 5 | var MenuOptions = module.exports = React.createClass({ 6 | 7 | contextTypes: { 8 | id: React.PropTypes.string, 9 | active: React.PropTypes.bool 10 | }, 11 | 12 | getInitialState: function() { 13 | return {activeIndex: 0} 14 | }, 15 | 16 | mixins: [buildClassName], 17 | 18 | onSelectionMade: function() { 19 | this.props.onSelectionMade(); 20 | }, 21 | 22 | 23 | moveSelectionUp: function() { 24 | this.updateFocusIndexBy(-1); 25 | }, 26 | 27 | moveSelectionDown: function() { 28 | this.updateFocusIndexBy(1); 29 | }, 30 | 31 | handleKeys: function(e) { 32 | var options = { 33 | 'ArrowDown': this.moveSelectionDown, 34 | 'ArrowUp': this.moveSelectionUp, 35 | 'Escape': this.closeMenu 36 | }; 37 | if(options[e.key]){ 38 | options[e.key].call(this); 39 | } 40 | }, 41 | 42 | normalizeSelectedBy: function(delta, numOptions){ 43 | this.selectedIndex += delta; 44 | if (this.selectedIndex > numOptions - 1) { 45 | this.selectedIndex = 0; 46 | } else if (this.selectedIndex < 0) { 47 | this.selectedIndex = numOptions - 1; 48 | } 49 | }, 50 | 51 | focusOption: function(index) { 52 | this.selectedIndex = index; 53 | this.updateFocusIndexBy(0); 54 | }, 55 | 56 | updateFocusIndexBy: function(delta) { 57 | var optionNodes = findDOMNode(this).querySelectorAll('.Menu__MenuOption'); 58 | this.normalizeSelectedBy(delta, optionNodes.length); 59 | this.setState({activeIndex: this.selectedIndex}, function () { 60 | optionNodes[this.selectedIndex].focus(); 61 | }); 62 | }, 63 | 64 | renderOptions: function() { 65 | var index = 0; 66 | return React.Children.map(this.props.children, function(c){ 67 | var clonedOption = c; 68 | if (c.type === MenuOption) { 69 | var active = this.state.activeIndex === index; 70 | clonedOption = React.cloneElement(c, { 71 | active: active, 72 | index: index, 73 | _internalFocus: this.focusOption, 74 | _internalSelect: this.onSelectionMade 75 | }); 76 | index++; 77 | } 78 | return clonedOption; 79 | }.bind(this)); 80 | }, 81 | 82 | buildName: function() { 83 | var cn = this.buildClassName('Menu__MenuOptions'); 84 | cn += ' Menu__MenuOptions--horizontal-' + this.props.horizontalPlacement; 85 | cn += ' Menu__MenuOptions--vertical-' + this.props.verticalPlacement; 86 | return cn; 87 | }, 88 | 89 | render: function() { 90 | return ( 91 | 102 | ) 103 | } 104 | 105 | }); 106 | -------------------------------------------------------------------------------- /lib/components/MenuTrigger.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var buildClassName = require('../mixins/buildClassName'); 3 | 4 | var MenuTrigger = module.exports = React.createClass({ 5 | 6 | contextTypes: { 7 | id: React.PropTypes.string, 8 | active: React.PropTypes.bool 9 | }, 10 | 11 | mixins: [buildClassName], 12 | 13 | toggleActive: function() { 14 | this.props.onToggleActive(!this.context.active); 15 | }, 16 | 17 | handleKeyUp: function(e) { 18 | if (e.key === ' ') 19 | this.toggleActive(); 20 | }, 21 | 22 | handleKeyDown: function(e) { 23 | if (e.key === 'Enter') 24 | this.toggleActive(); 25 | }, 26 | 27 | handleClick: function() { 28 | this.toggleActive(); 29 | }, 30 | 31 | render: function() { 32 | var triggerClassName = 33 | this.buildClassName( 34 | 'Menu__MenuTrigger ' + 35 | (this.context.active 36 | ? 'Menu__MenuTrigger__active' 37 | : 'Menu__MenuTrigger__inactive') 38 | ); 39 | 40 | return ( 41 |
51 | {this.props.children} 52 |
53 | ) 54 | } 55 | 56 | }); 57 | -------------------------------------------------------------------------------- /lib/helpers/injectCSS.js: -------------------------------------------------------------------------------- 1 | var jss = require('js-stylesheet'); 2 | 3 | module.exports = function() { 4 | jss({ 5 | '.Menu': { 6 | position: 'relative' 7 | }, 8 | '.Menu__MenuOptions': { 9 | border: '1px solid #ccc', 10 | 'border-radius': '3px', 11 | background: '#FFF', 12 | position: 'absolute' 13 | }, 14 | '.Menu__MenuOption': { 15 | padding: '5px', 16 | 'border-radius': '2px', 17 | outline: 'none', 18 | cursor: 'pointer' 19 | }, 20 | '.Menu__MenuOption--disabled': { 21 | 'background-color': '#eee', 22 | }, 23 | '.Menu__MenuOption--active': { 24 | 'background-color': '#0aafff', 25 | }, 26 | '.Menu__MenuOption--active.Menu__MenuOption--disabled': { 27 | 'background-color': '#ccc' 28 | }, 29 | '.Menu__MenuTrigger': { 30 | border: '1px solid #ccc', 31 | 'border-radius': '3px', 32 | padding: '5px', 33 | background: '#FFF' 34 | }, 35 | '.Menu__MenuOptions--horizontal-left': { 36 | right: '0px' 37 | }, 38 | '.Menu__MenuOptions--horizontal-right': { 39 | left: '0px' 40 | }, 41 | '.Menu__MenuOptions--vertical-top': { 42 | bottom: '45px' 43 | }, 44 | '.Menu__MenuOptions--vertical-bottom': { 45 | } 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /lib/helpers/uuid.js: -------------------------------------------------------------------------------- 1 | var count = 0; 2 | module.exports = function () { 3 | return 'react-menu-' + count++; 4 | }; 5 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var Menu = require('./components/Menu'); 2 | Menu.MenuTrigger = require('./components/MenuTrigger'); 3 | Menu.MenuOptions = require('./components/MenuOptions'); 4 | Menu.MenuOption = require('./components/MenuOption'); 5 | 6 | module.exports = Menu; 7 | -------------------------------------------------------------------------------- /lib/mixins/buildClassName.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | buildClassName: function(baseName) { 4 | var name = baseName; 5 | if (this.props.className) { 6 | name += ' ' + this.props.className; 7 | } 8 | return name; 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-menu", 3 | "version": "0.0.10", 4 | "description": "Accessible menu component for React.JS", 5 | "main": "./lib/index", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/instructure-react/react-menu.git" 9 | }, 10 | "homepage": "https://github.com/instructure-react/react-menu", 11 | "bugs": "https://github.com/instructure-react/react-menu/issues", 12 | "directories": { 13 | "example": "examples" 14 | }, 15 | "scripts": { 16 | "test": "scripts/test --browsers Chrome --single-run", 17 | "start": "scripts/dev-examples" 18 | }, 19 | "authors": [ 20 | "Jason Madsen" 21 | ], 22 | "license": "MIT", 23 | "devDependencies": { 24 | "babel-core": "6.13.2", 25 | "babel-loader": "6.2.4", 26 | "babel-plugin-transform-object-rest-spread": "6.8.0", 27 | "babel-preset-es2015": "6.13.2", 28 | "babel-preset-react": "6.11.1", 29 | "envify": "3.4.0", 30 | "expect": "1.14.0", 31 | "karma": "0.13.19", 32 | "karma-chrome-launcher": "0.2.2", 33 | "karma-cli": "0.1.2", 34 | "karma-firefox-launcher": "0.1.7", 35 | "karma-mocha": "0.2.1", 36 | "karma-webpack": "1.8.0", 37 | "lodash.assign": "^4.1.0", 38 | "mocha": "2.4.5", 39 | "react": "^0.14.7", 40 | "react-addons-test-utils": "^0.14.7", 41 | "react-dom": "^0.14.7", 42 | "rf-release": "0.4.0", 43 | "uglify-js": "2.6.1", 44 | "webpack": "1.13.1", 45 | "webpack-dev-server": "1.14.1" 46 | }, 47 | "peerDependencies": { 48 | "react": "^0.14.0 || ^15.0.0-0" 49 | }, 50 | "tags": [ 51 | "react", 52 | "menu", 53 | "dropdown" 54 | ], 55 | "keywords": [ 56 | "react", 57 | "react-component", 58 | "menu", 59 | "dropdown" 60 | ], 61 | "dependencies": { 62 | "js-stylesheet": "0.0.1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | mkdir -p dist 3 | node_modules/.bin/webpack 4 | node_modules/.bin/uglifyjs dist/react-menu.js \ 5 | --compress warnings=false > dist/react-menu.min.js 6 | -------------------------------------------------------------------------------- /scripts/dev-examples: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | node_modules/.bin/webpack-dev-server --inline --config webpack.examples-config.js --content-base examples/ 3 | -------------------------------------------------------------------------------- /scripts/preview-release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | node_modules/rf-release/node_modules/.bin/changelog -t preview -s 3 | -------------------------------------------------------------------------------- /scripts/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | scripts/build 3 | node_modules/.bin/release 4 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | NODE_ENV=test node_modules/.bin/karma start "$@" 3 | -------------------------------------------------------------------------------- /specs/Menu.spec.js: -------------------------------------------------------------------------------- 1 | require('./helper'); 2 | var findDOMNode = require('react-dom').findDOMNode; 3 | 4 | describe('Menu', function () { 5 | describe('a single menu', function() { 6 | 7 | var menu; 8 | 9 | beforeEach(function () { 10 | menu = renderMenu(); 11 | }); 12 | 13 | afterEach(function () { 14 | unmountMenu(); 15 | }); 16 | 17 | it('should hide menu options by default', function () { 18 | equal(findDOMNode(menu.refs.options).style.visibility, 'hidden'); 19 | equal(findDOMNode(menu.refs.options).getAttribute('aria-expanded'), 'false'); 20 | equal(findDOMNode(menu.refs.options).style.visibility, 'hidden'); 21 | ok(!menu.state.active); 22 | }); 23 | 24 | it('should show menu options when trigger is clicked', function () { 25 | TestUtils.Simulate.click(findDOMNode(menu.refs.trigger)); 26 | equal(findDOMNode(menu.refs.options).getAttribute('aria-expanded'), 'true'); 27 | equal(findDOMNode(menu.refs.options).style.visibility, 'visible'); 28 | ok(menu.state.active); 29 | }); 30 | 31 | it('should toggle menu options trigger on enter key', function () { 32 | TestUtils.Simulate.keyDown(findDOMNode(menu.refs.trigger), {key: 'Enter'}); 33 | ok(menu.state.active); 34 | }); 35 | 36 | it('should focus first option', function () { 37 | TestUtils.Simulate.click(findDOMNode(menu.refs.trigger)); 38 | equal(findDOMNode(menu.refs.options).children[0], document.activeElement); 39 | }); 40 | 41 | it('should have roles and aria attributes', function () { 42 | var trigger = findDOMNode(menu.refs.trigger); 43 | var options = findDOMNode(menu.refs.options); 44 | equal(trigger.getAttribute('aria-owns'), options.getAttribute('id')); 45 | equal(trigger.getAttribute('role'), 'button'); 46 | equal(trigger.getAttribute('aria-haspopup'), 'true'); 47 | equal(options.getAttribute('role'), 'menu'); 48 | equal(options.getAttribute('aria-expanded'), 'false'); 49 | equal(options.children[0].getAttribute('role'), 'menuitem'); 50 | }); 51 | 52 | // TODO: These tests aren't working for some reason 53 | // it('should change selectedIndex on keydown', function () { 54 | // TestUtils.Simulate.click(findDOMNode(menu.refs.trigger)); 55 | // TestUtils.Simulate.keyDown(findDOMNode(menu.refs.options), {key: 'ArrowDown'}); 56 | // equal(menu.state.selectedIndex, 1); 57 | // }); 58 | 59 | // it('should select menu option on enter', function () { 60 | // TestUtils.Simulate.click(findDOMNode(menu.refs.trigger)); 61 | // TestUtils.Simulate.keyDown(findDOMNode(menu.refs.options).children[1], {key: 'Enter'}); 62 | // equal(menu.state.selectedIndex, 1); 63 | // }); 64 | 65 | it('should make menu option disabled', function () { 66 | equal(findDOMNode(menu.refs.options).children[3].getAttribute('aria-disabled'), 'true'); 67 | }); 68 | }); 69 | 70 | describe('multiple menus', function () { 71 | 72 | var menuA, menuB, containerA, containerB; 73 | 74 | beforeEach(function () { 75 | containerA = document.createElement("div"); 76 | containerB = document.createElement("div"); 77 | 78 | menuA = renderMenu(containerA); 79 | menuB = renderMenu(containerB); 80 | }); 81 | 82 | afterEach(function () { 83 | unmountMenu(containerA); 84 | unmountMenu(containerB); 85 | }); 86 | 87 | it('should close the active menu when clicking another menu', function (done) { 88 | TestUtils.Simulate.click(findDOMNode(menuA.refs.trigger)); 89 | ok(menuA.state.active); 90 | 91 | TestUtils.Simulate.click(findDOMNode(menuB.refs.trigger)); 92 | // Unfortunate implementation detail that `active` is not reset until the next execution cycle 93 | setTimeout(function() { 94 | ok(!menuA.state.active); 95 | ok(menuB.state.active); 96 | done(); 97 | }, 100); 98 | }); 99 | }); 100 | 101 | }); 102 | -------------------------------------------------------------------------------- /specs/buildClassName.spec.js: -------------------------------------------------------------------------------- 1 | require('./helper'); 2 | var React = require('react'); 3 | var render = require('react-dom').render; 4 | var findDOMNode = require('react-dom').findDOMNode; 5 | var buildClassName = require('../lib/mixins/buildClassName'); 6 | 7 | var MockComponent = React.createFactory(React.createClass({ 8 | mixins: [buildClassName], 9 | 10 | render: function (){ 11 | return ( 12 | React.DOM.div({ 13 | className: this.buildClassName('foo') 14 | }) 15 | ) 16 | } 17 | })); 18 | 19 | describe('buildClassName', function () { 20 | 21 | it('includes the name passed in', function() { 22 | var _currentDiv = document.createElement('div'); 23 | var menu = render(MockComponent(), _currentDiv); 24 | equal(findDOMNode(menu).className, 'foo'); 25 | }); 26 | 27 | it('concatenates existing classNames passed in', function() { 28 | var _currentDiv = document.createElement('div'); 29 | var menu = render(MockComponent({ 30 | className: 'bar' 31 | }), _currentDiv); 32 | equal(findDOMNode(menu).className, 'foo bar'); 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /specs/helper.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var React = require('react'); 3 | var ReactDOM = require('react-dom'); 4 | window.TestUtils = require('react-addons-test-utils'); 5 | var Menu = require('../lib/components/Menu'); 6 | var MenuTrigger = require('../lib/components/MenuTrigger'); 7 | var MenuOptions = require('../lib/components/MenuOptions'); 8 | var MenuOption = require('../lib/components/MenuOption'); 9 | 10 | window.ok = assert.ok; 11 | window.equal = assert.equal; 12 | window.strictEqual = assert.strictEqual; 13 | window.throws = assert.throws; 14 | 15 | var memorizedContainer; 16 | window.renderMenu = function(container) { 17 | container = container || document.createElement('div'); 18 | document.body.appendChild(container); 19 | memorizedContainer = container; 20 | return ReactDOM.render(( 21 | 22 | I am the trigger, goo goo goo joob 23 | 24 | Foo 25 | Bar 26 | Baz 27 | Disabled 28 | 29 | 30 | ), container); 31 | }; 32 | 33 | window.unmountMenu = function(container) { 34 | container = container || memorizedContainer; 35 | ReactDOM.unmountComponentAtNode(container); 36 | container.remove(); 37 | }; 38 | 39 | -------------------------------------------------------------------------------- /specs/main.js: -------------------------------------------------------------------------------- 1 | require('./buildClassName.spec.js'); 2 | require('./Menu.spec.js'); 3 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: { 3 | 'react-menu': './lib/index.js' 4 | }, 5 | 6 | output: { 7 | library: "ReactMenu", 8 | libraryTarget: "umd", 9 | filename: '[name].js', 10 | path: 'dist', 11 | }, 12 | 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.js$/, 17 | loader: 'babel', 18 | query: { 19 | presets: ['react', 'es2015'], 20 | plugins: ['transform-object-rest-spread'], 21 | }, 22 | } 23 | ] 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /webpack.examples-config.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | var EXAMPLES_DIR = path.resolve(__dirname, 'examples'); 5 | 6 | function isDirectory(dir) { 7 | return fs.lstatSync(dir).isDirectory(); 8 | } 9 | 10 | function buildEntries() { 11 | return fs.readdirSync(EXAMPLES_DIR).reduce(function (entries, dir) { 12 | if (dir === 'build') 13 | return entries; 14 | 15 | var isDraft = dir.charAt(0) === '_'; 16 | 17 | if (!isDraft && isDirectory(path.join(EXAMPLES_DIR, dir))) 18 | entries[dir] = path.join(EXAMPLES_DIR, dir, 'app.js'); 19 | 20 | return entries; 21 | }, {}); 22 | } 23 | 24 | module.exports = { 25 | entry: buildEntries(), 26 | 27 | output: { 28 | filename: '[name].js', 29 | chunkFilename: '[id].chunk.js', 30 | path: 'examples/__build__', 31 | publicPath: '/__build__/' 32 | }, 33 | 34 | module: { 35 | loaders: [ 36 | { 37 | test: /\.js$/, 38 | exclude: [/node_modules/], // speed up examples by not babel-ing dependencies 39 | loader: 'babel', 40 | query: { 41 | presets: ['react', 'es2015'], 42 | plugins: ['transform-object-rest-spread'], 43 | }, 44 | } 45 | ] 46 | }, 47 | }; 48 | --------------------------------------------------------------------------------