├── .gitignore ├── jest-preprocessor.js ├── example ├── README.md ├── css │ └── style.css ├── index.html ├── package.json └── src │ └── js │ ├── ChooseCity.js │ └── main.js ├── LICENSE ├── src ├── __tests__ │ ├── DropdownInput-shallow-test.js │ └── DropdownInput-deep-test.js └── DropdownInput.js ├── package.json ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | bundle.js 3 | bundle.min.js -------------------------------------------------------------------------------- /jest-preprocessor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var ReactTools = require('react-tools'); 3 | module.exports = { 4 | process: function(src) { 5 | return ReactTools.transform(src); 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | React-Dropdown-Input Example 2 | ============================ 3 | 4 | See this live at: 5 | 6 | [http://racingtadpole.github.io/react-dropdown-input/example/](http://racingtadpole.github.io/react-dropdown-input/example/) 7 | 8 | Or, clone this repo and build it from source with: 9 | 10 | cd example 11 | npm install 12 | npm run build 13 | python -m SimpleHTTPServer # eg. serves at localhost:8000 14 | 15 | -------------------------------------------------------------------------------- /example/css/style.css: -------------------------------------------------------------------------------- 1 | .dropdown-input.dropdown-menu>li>a:hover, .dropdown-input.dropdown-menu>li>a:focus { 2 | color:#333; 3 | background-color:#fff; 4 | } 5 | .dropdown-input.dropdown-menu>.active>a:hover, .dropdown-input.dropdown-menu>.active>a:focus { 6 | text-decoration:none; 7 | color:#fff; 8 | background-color:#337ab7; 9 | } 10 | .dropdown-input.dropdown-menu>.disabled>a { 11 | color: #999; 12 | font-style: italic; 13 | } 14 | .dropdown-input.dropdown-menu>.active.disabled>a { 15 | text-decoration:none; 16 | color: #999; 17 | background-color:#fff; 18 | } -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Dropdown Input Example 1 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 RacingTadpole 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dropdown-input-example", 3 | "version": "0.1.0", 4 | "description": "Example of react-dropdown-input", 5 | "homepage": "http://racingtadpole.github.io/react-dropdown-input/example/", 6 | "main": "index.html", 7 | "dependencies": { 8 | "react": ">=0.13", 9 | "react-bootstrap": ">=0.18.0", 10 | "react-dropdown-input": "../" 11 | }, 12 | "devDependencies": { 13 | "browserify": "^6.2.0", 14 | "reactify": "^0.15.2", 15 | "uglify-js": "^2.4.19" 16 | }, 17 | "scripts": { 18 | "build": "browserify src/js/*.js | uglifyjs -cm > bundle.min.js", 19 | "test": "echo 'No tests yet' && exit 1" 20 | }, 21 | "browserify": { 22 | "transform": [ 23 | "reactify" 24 | ] 25 | }, 26 | "author": "Arthur Street (http://racingtadpole.com/)", 27 | "license": "MIT", 28 | "private": "true", 29 | "keywords": [ 30 | "react", 31 | "ecosystem-react", 32 | "react-component", 33 | "bootstrap", 34 | "combobox", 35 | "autocomplete", 36 | "example" 37 | ], 38 | "//": [ 39 | "to manually rebuild after a change in the react-dropdown-input module, you can use:", 40 | "cd .. ; npm run build && cd example && rm -rf node_modules/react-dropdown-input/ && npm install && npm run build" 41 | ] 42 | } 43 | 44 | -------------------------------------------------------------------------------- /example/src/js/ChooseCity.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var React = require('react'); 3 | var DropdownInput = require('react-dropdown-input'); 4 | 5 | var ChooseCity = 6 | React.createClass({ 7 | 8 | propTypes: { 9 | lookupText: React.PropTypes.string, 10 | options: React.PropTypes.oneOfType([React.PropTypes.object, React.PropTypes.array]).isRequired 11 | }, 12 | 13 | getInitialState: function() { 14 | return {cityText: null}; 15 | }, 16 | 17 | handleSelect: function(choice) { 18 | // returns choice.value and choice.index 19 | if (choice.index>=0) { 20 | this.setState({cityText: choice.value + ' is a nice choice'}); 21 | } else { 22 | this.setState({cityText: choice.value + ' isn\'t on the list!'}); 23 | } 24 | }, 25 | 26 | render: function() { 27 | return ( 28 |
29 | 37 |
38 |
{this.state.cityText}
39 |
40 |
41 | ); 42 | } 43 | }); 44 | 45 | module.exports = ChooseCity; 46 | -------------------------------------------------------------------------------- /example/src/js/main.js: -------------------------------------------------------------------------------- 1 | // 2 | // main.js 3 | // 4 | 'use strict'; 5 | 6 | var React = require('react'); 7 | var ChooseCity = require('./ChooseCity'); 8 | 9 | var names = [ 10 | 'Sydney', 11 | 'Melbourne', 12 | 'Brisbane', 13 | 'Perth', 14 | 'Adelaide', 15 | 'Gold Coast', 16 | 'Newcastle', 17 | 'Canberra', 18 | 'Sunshine Coast', 19 | 'Wollongong', 20 | 'Hobart', 21 | 'Geelong', 22 | 'Townsville', 23 | 'Cairns', 24 | 'Darwin', 25 | 'Toowoomba', 26 | 'Ballarat', 27 | 'Bendigo', 28 | 'Launceston', 29 | 'Albury-Wodonga', 30 | 'Mackay', 31 | 'Rockhampton', 32 | 'Bundaberg', 33 | 'Bunbury', 34 | 'Coffs Harbour', 35 | 'Wagga Wagga', 36 | 'Hervey Bay', 37 | 'Mildura', 38 | 'Shepparton', 39 | 'Gladstone', 40 | 'Port Macquarie', 41 | 'Tamworth', 42 | 'Traralgon', 43 | 'Orange', 44 | 'Geraldton', 45 | 'Bowral', 46 | 'Dubbo', 47 | 'Nowra', 48 | 'Bathurst', 49 | 'Warrnambool', 50 | 'Kalgoorlie', 51 | 'Busselton', 52 | 'Albany', 53 | 'Warragul', 54 | 'Devonport' 55 | ].sort(); 56 | 57 | React.render( 58 |
59 |
60 |
61 |

Welcome

62 |

Please choose a city

63 | 64 | View on Github 65 |
66 |
67 |
68 | , document.getElementById('example') 69 | ); 70 | -------------------------------------------------------------------------------- /src/__tests__/DropdownInput-shallow-test.js: -------------------------------------------------------------------------------- 1 | // 2 | // In these tests, we mock React-Bootstrap, so we can only check it has the right components 3 | // 4 | 5 | /*global jest, describe, it, expect */ 6 | 'use strict'; 7 | 8 | jest.dontMock('../DropdownInput'); 9 | var React = require('react/addons'); 10 | var DropdownInput = require('../DropdownInput'); 11 | var ReactBootstrap = require('react-bootstrap'); 12 | var TestUtils = React.addons.TestUtils; 13 | 14 | describe('DropdownInput', function() { 15 | 16 | var names = ['Aab', 'abcd', 'Cde', 'Def', 'Deb', 'Eabc']; 17 | var menuClassName = 'jest-test'; 18 | var id = 'test-id'; 19 | var element = (); 20 | var result; 21 | 22 | beforeEach(function() { 23 | var shallowRenderer = TestUtils.createRenderer(); 24 | shallowRenderer.render(element); 25 | result = shallowRenderer.getRenderOutput(); 26 | }); 27 | 28 | it('contains the right components', function() { 29 | // there should be one Input component and one DropdownMenu component 30 | // for now, assume in that order - TODO: generalize 31 | var child0 = result.props.children[0]; 32 | var child1 = result.props.children[1]; 33 | expect(child0.type).toEqual(ReactBootstrap.Input); 34 | expect(child1.type).toEqual(ReactBootstrap.DropdownMenu); 35 | // also check the dropdown menu has been passed the right class name 36 | expect(child1.props.className).toEqual(menuClassName); 37 | }); 38 | 39 | it('contains the right menu items', function() { 40 | var child1 = result.props.children[1]; 41 | var child1props = child1.props.children[0]; // not very general 42 | expect(child1props[0].props.children).toContain(names[0]); 43 | }); 44 | 45 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dropdown-input", 3 | "version": "0.1.11", 4 | "description": "Displays a Bootstrap Input element with a DropdownMenu of possible options (similar to a combobox)", 5 | "homepage": "http://racingtadpole.github.io/react-dropdown-input/", 6 | "main": "index.js", 7 | "dependencies": { 8 | "classnames": ">=1.1.4", 9 | "react": ">=0.12", 10 | "react-bootstrap": ">=0.18.0" 11 | }, 12 | "devDependencies": { 13 | "babel": "^4.7.16", 14 | "babel-eslint": "^2.0.2", 15 | "eslint": "^0.18.0", 16 | "eslint-plugin-react": "^2.0.2", 17 | "jest-cli": "0.2.1", 18 | "react-tools": "^0.13.1" 19 | }, 20 | "jest": { 21 | "scriptPreprocessor": "/jest-preprocessor.js", 22 | "unmockedModulePathPatterns": ["/node_modules/react"] 23 | }, 24 | "//": [ 25 | "to run tests, I use node 0.10.x and jest-cli 0.2.1 as jest 0.4.0 doesn't work on my Mac OS with node v0.12.x", 26 | "but to build, I use node 0.12.x because babel won't run on node 0.10.x" 27 | ], 28 | "scripts": { 29 | "build": "npm run lint && node_modules/.bin/babel src/DropdownInput.js > index.js", 30 | "lint": "./node_modules/eslint/bin/eslint.js src/DropdownInput.js example/src/js/ChooseCity.js", 31 | "test": "jest", 32 | "test-debug": "node-debug --nodejs --harmony ./node_modules/jest-cli/bin/jest.js --runInBand" 33 | }, 34 | "author": "Arthur Street (http://racingtadpole.com/)", 35 | "license": "MIT", 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/RacingTadpole/react-dropdown-input.git" 39 | }, 40 | "bugs": { 41 | "url": "https://github.com/RacingTadpole/react-dropdown-input/issues" 42 | }, 43 | "keywords": [ 44 | "react", 45 | "ecosystem-react", 46 | "react-component", 47 | "bootstrap", 48 | "combobox", 49 | "autocomplete" 50 | ], 51 | "eslintConfig": { 52 | "parser": "babel-eslint", 53 | "plugins": [ 54 | "react" 55 | ], 56 | "ecmaFeatures": { 57 | "jsx": true 58 | }, 59 | "env": { 60 | "node": true, 61 | "browser": true 62 | }, 63 | "rules": { 64 | "react/display-name": 0, 65 | "react/jsx-quotes": 1, 66 | "react/jsx-no-undef": 1, 67 | "react/jsx-sort-props": 0, 68 | "react/jsx-uses-react": 1, 69 | "react/jsx-uses-vars": 1, 70 | "react/no-did-mount-set-state": 1, 71 | "react/no-did-update-set-state": 1, 72 | "react/no-multi-comp": 1, 73 | "react/no-unknown-property": 1, 74 | "react/prop-types": 2, 75 | "react/react-in-jsx-scope": 1, 76 | "react/self-closing-comp": 1, 77 | "react/wrap-multilines": 1, 78 | "quotes": [ 79 | 1, 80 | "single", 81 | "avoid-escape" 82 | ] 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/__tests__/DropdownInput-deep-test.js: -------------------------------------------------------------------------------- 1 | // 2 | // In these tests, we do NOT mock React-Bootstrap, so we can check it has the right behaviour 3 | // 4 | 5 | /*global jest, describe, it, expect, beforeEach */ 6 | 'use strict'; 7 | 8 | jest.dontMock('../DropdownInput'); 9 | jest.dontMock('react-bootstrap'); 10 | jest.dontMock('classnames'); 11 | var React = require('react/addons'); 12 | var DropdownInput = require('../DropdownInput'); 13 | var ReactBootstrap = require('react-bootstrap'); 14 | var TestUtils = React.addons.TestUtils; 15 | 16 | describe('DropdownInput', function() { 17 | 18 | var names = ['Aab', 'abcd', 'Cde', 'Def', 'Deb', 'Eabc']; 19 | var menuClassName = 'jest-test'; 20 | var id = 'test-id'; 21 | var element = (); 22 | var renderedItem; 23 | 24 | beforeEach(function() { 25 | renderedItem = TestUtils.renderIntoDocument(element); 26 | }); 27 | 28 | it('contains an initially empty input tag', function() { 29 | var input = TestUtils.findRenderedDOMComponentWithTag(renderedItem, 'input').getDOMNode(); 30 | expect(input.textContent).toEqual(''); 31 | }); 32 | 33 | it('contains the menu class and items', function() { 34 | var menu = TestUtils.findRenderedDOMComponentWithClass(renderedItem, menuClassName).getDOMNode(); 35 | var items = menu.children; 36 | for (var i=0; i < items.length; i++) { 37 | expect(items[i].textContent).toEqual(names[i]); 38 | } 39 | }); 40 | 41 | it('adjusts the menu when the input changes', function() { 42 | var txt = 'a'; 43 | var input = TestUtils.findRenderedDOMComponentWithTag(renderedItem, 'input').getDOMNode(); 44 | //TestUtils.Simulate.keyDown(input, {key: 'a'}); // this doesn't work 45 | TestUtils.Simulate.change(input, {target: {value: txt}}); 46 | expect(input.value).toEqual(txt); 47 | // did the menu respond appropriately? 48 | var menu = TestUtils.findRenderedDOMComponentWithClass(renderedItem, menuClassName).getDOMNode(); 49 | var items = menu.children; 50 | var matchingNames = names.filter(function(n) { return n.indexOf(txt)>=0; }); 51 | expect(items.length).toEqual(matchingNames.length); 52 | for (var i=0; i < items.length; i++) { 53 | expect(items[i].textContent).toEqual(matchingNames[i]); 54 | } 55 | }); 56 | 57 | it('maxes out with a final message', function() { 58 | var max = 3; 59 | var element = (); 60 | var renderedItem = TestUtils.renderIntoDocument(element); 61 | var menu = TestUtils.findRenderedDOMComponentWithClass(renderedItem, menuClassName).getDOMNode(); 62 | var items = menu.children; 63 | expect(items.length).toEqual(max); 64 | expect(items[max - 1].textContent).toEqual('+' + (names.length - max + 1) + ' more'); 65 | }); 66 | 67 | it('still shows all options if length == max', function() { 68 | var max = names.length; 69 | var element = (); 70 | var renderedItem = TestUtils.renderIntoDocument(element); 71 | var menu = TestUtils.findRenderedDOMComponentWithClass(renderedItem, menuClassName).getDOMNode(); 72 | var items = menu.children; 73 | expect(items.length).toEqual(max); 74 | expect(items[max - 1].textContent).toEqual(names[names.length - 1]); 75 | }); 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm package](https://img.shields.io/npm/v/react-dropdown-input.svg?style=flat-square)](https://www.npmjs.org/package/react-dropdown-input) 2 | 3 | React-Dropdown-Input 4 | ==================== 5 | 6 | React-dropdown-input displays a "combobox" for [React](http://facebook.github.io/react/). 7 | More explicitly, it shows a text input box; once the user starts to type, a dropdown menu 8 | opens with matching options. The user can choose one of those options either by clicking one, 9 | or by using the arrow keys and hitting Enter. 10 | 11 | It is styled with bootstrap, using the [React-Bootstrap](http://react-bootstrap.github.io/) package; it actually displays a `ReactBootstrap.Input` element 12 | with a `ReactBootstrap.DropdownMenu` of possible options. 13 | 14 | ## Demo 15 | 16 | [http://racingtadpole.github.io/react-dropdown-input/example/](http://racingtadpole.github.io/react-dropdown-input/example/) 17 | 18 | ## Installation 19 | 20 | npm install react-dropdown-input --save 21 | 22 | ## Sample Usage 23 | 24 | var searchNames = ['Sydney', 'Melbourne', 'Brisbane', 25 | 'Adelaide', 'Perth', 'Hobart']; 26 | //... 27 | 34 | 35 | ## In more detail... 36 | 37 | The options are simply passed as a javascript array (or [immutablejs](http://facebook.github.io/immutable-js/) object) 38 | to the `options` prop. 39 | 40 | Supply one or both of these callbacks: `onSelect` & `onChange`. 41 | 42 | - `onSelect` fires when an option is clicked, or when Enter is pressed. 43 | It passes the object: 44 | 45 | { value: input text, 46 | index: option index, or -1 if user entered their own text and pressed Enter 47 | } 48 | - `onChange` fires whenever the input text value changes, either due to a click or typing. 49 | It passes the object: 50 | 51 | { value: input text } 52 | 53 | Other props you can pass: 54 | 55 | - `filter`: a function that determines which options to show, given the input text 56 | (see `defaultFilter` in the code for the default). 57 | - `menuClassName`: a class for the menu, which you need for the css styling below; 58 | eg. 'dropdown-input'. 59 | - `max`: the maximum number of options to display. 60 | - `maxText`: text of a disabled MenuItem to show at the end of a list, if the max is exceeded 61 | replaces '#' with the number not shown; defaults to '+# more not shown'. 62 | 63 | You can also pass `` all the properties that `` allows, 64 | eg. `ButtonAfter`. 65 | 66 | IMPORTANT NOTE ABOUT CSS 67 | ------------------------ 68 | 69 | You need to turn off Bootstrap's hover highlighting css for this element; 70 | we do it manually using the active class instead. You may also need to re-enable 71 | the hover highlighting on the active class. Eg. in sass, add this: 72 | 73 | .dropdown-input.dropdown-menu > li > a { 74 | &:hover, 75 | &:focus { 76 | color: $dropdown-link-color; // #333 77 | background-color: $dropdown-bg; // #fff 78 | } 79 | } 80 | .dropdown-input.dropdown-menu > .active > a { 81 | &:hover, 82 | &:focus { 83 | text-decoration: none; 84 | color: $dropdown-link-hover-color; // #fff 85 | background-color: $dropdown-link-hover-bg; // #337ab7 86 | } 87 | } 88 | 89 | If you're showing `maxText`, you may also want to make sure it can't be selected too: 90 | 91 | .dropdown-input.dropdown-menu>.active.disabled>a { 92 | text-decoration: none; 93 | color: $dropdown-link-disabled-color; // #777 94 | background-color: $dropdown-bg; // #fff 95 | } 96 | 97 | ## Release History 98 | 99 | * 0.1.0 Initial release 100 | * 0.1.1 Point to js (not jsx), update README 101 | * 0.1.2 Update example 102 | * 0.1.3 Align package.json version number with git tag 103 | * 0.1.4 Added maxText property 104 | * 0.1.5 Added eslint to dev 105 | * 0.1.6 Corrected number not shown 106 | * 0.1.7 Don't pass options and menuClassName props through to Input 107 | * 0.1.8 Added working tests using jest 108 | * 0.1.9 Use self-closing tag in ReadMe 109 | * 0.1.10 Remove extra comments, rename var as DropdownInput 110 | * 0.1.11 Prevent form submission if open 111 | -------------------------------------------------------------------------------- /src/DropdownInput.js: -------------------------------------------------------------------------------- 1 | // 2 | // react-dropdown-input 3 | // Displays a ReactBootstrap.Input element 4 | // with a ReactBootstrap.DropdownMenu of possible options. 5 | // 6 | 7 | 'use strict'; 8 | 9 | var React = require('react/addons'); 10 | var ReactBootstrap = require('react-bootstrap'); 11 | var joinClasses = require('react/lib/joinClasses'); 12 | var cx = require('classnames'); 13 | 14 | var BootstrapMixin = ReactBootstrap.BootstrapMixin; 15 | var DropdownStateMixin = ReactBootstrap.DropdownStateMixin; 16 | var DropdownMenu = ReactBootstrap.DropdownMenu; 17 | var Input = ReactBootstrap.Input; 18 | var MenuItem = ReactBootstrap.MenuItem; 19 | 20 | var defaultMaxText = '+# more not shown'; 21 | 22 | var defaultFilter = function(filterText, optionName) { // also optionIndex as third arg 23 | return (optionName.toLowerCase().indexOf(filterText.toLowerCase()) >= 0); 24 | }; 25 | 26 | var genLength = function(list) { 27 | // deal with both regular arrays and immutablejs objects, which have .count() instead of length 28 | return (typeof list.count !== 'undefined' ? list.count() : list.length); 29 | }; 30 | 31 | var genGet = function(list, i) { 32 | // deal with both regular arrays and immutablejs objects, which have list.get(i) instead of list[i] 33 | return (typeof list.get !== 'undefined' ? list.get(i) : list[i]); 34 | }; 35 | 36 | var caseInsensIndexOf = function(list, str) { 37 | var lowerList = list.map(function(item) { return item.toLowerCase(); }); 38 | return lowerList.indexOf(str.toLowerCase()); 39 | }; 40 | 41 | 42 | var DropdownInput = React.createClass({ 43 | 44 | mixins: [BootstrapMixin, DropdownStateMixin], 45 | 46 | propTypes: { 47 | pullRight: React.PropTypes.bool, 48 | dropup: React.PropTypes.bool, 49 | defaultValue: React.PropTypes.string, 50 | menuClassName: React.PropTypes.string, 51 | max: React.PropTypes.number, 52 | maxText: React.PropTypes.string, 53 | onChange: React.PropTypes.func, 54 | onSelect: React.PropTypes.func, 55 | navItem: React.PropTypes.bool, 56 | options: React.PropTypes.oneOfType([React.PropTypes.object, React.PropTypes.array]).isRequired, 57 | filter: React.PropTypes.func, 58 | // the rest are to make eslint happy 59 | id: React.PropTypes.string, 60 | className: React.PropTypes.string, 61 | bsSize: React.PropTypes.string 62 | }, 63 | 64 | getInitialState: function () { 65 | return { 66 | value: this.props.defaultValue || '', 67 | activeIndex: -1 68 | }; 69 | }, 70 | 71 | filteredOptions: function() { 72 | var filter = this.props.filter || defaultFilter; 73 | return this.props.options.filter(filter.bind(undefined, this.state.value)); 74 | }, 75 | 76 | cappedLength: function(options) { 77 | var total = genLength(options); 78 | if (total>this.props.max) { 79 | // if it exceeded the max, we took an extra one off 80 | total = this.props.max - 1; 81 | } 82 | return total; 83 | }, 84 | 85 | render: function () { 86 | var classes = { 87 | 'dropdown': true, 88 | 'open': this.state.open, 89 | 'dropup': this.props.dropup 90 | }; 91 | // you can provide a filter prop, which is a function(filterText, optionName, optionIndex) which should 92 | // return true to show option with the given name and index, given the input filterText. 93 | var filteredOptions = this.filteredOptions(); 94 | var numFiltered = genLength(filteredOptions); 95 | var maxMenuItem = null; 96 | var maxText = typeof this.props.maxText === 'undefined' ? defaultMaxText : this.props.maxText; 97 | if (this.props.max && numFiltered > this.props.max) { 98 | // take an extra one off, to leave space for the maxText 99 | filteredOptions = filteredOptions.slice(0, this.props.max - 1); 100 | maxText = maxText.replace('#', (numFiltered - this.props.max + 1)); 101 | maxMenuItem = this.renderAsMenuItem(maxText, this.props.max, null, true); 102 | } 103 | var dropdown = null; 104 | if (numFiltered>0) { 105 | dropdown = ( 112 | {filteredOptions.map(this.renderAsMenuItem)} 113 | {maxMenuItem} 114 | ); 115 | } 116 | return ( 117 |
118 | 135 | {dropdown} 136 |
137 | ); 138 | }, 139 | 140 | renderAsMenuItem: function(item, index, options, disabled) { 141 | var start = item.toLowerCase().indexOf(this.state.value.toLowerCase()), 142 | end = start + this.state.value.length, 143 | part1 = item.slice(0, start), 144 | part2 = item.slice(start, end), 145 | part3 = item.slice(end); 146 | var classes = cx({active: this.state.activeIndex===index, disabled: disabled===true}); 147 | if (disabled) { 148 | // don't highlight parts of disabled items, eg. the maxText 149 | part1 = item; 150 | part2 = null; 151 | part3 = null; 152 | } 153 | return ( 154 | 159 | {part1}{part2}{part3} 160 | 161 | ); 162 | }, 163 | 164 | handleInputChange: function(e) { 165 | // the user changed the input text 166 | this.setState({value: e.target.value, activeIndex: -1}); 167 | this.setDropdownState(true); 168 | // fire the supplied onChange event. 169 | this.sendChange({value: e.target.value}); 170 | }, 171 | 172 | handleKeyDown: function(e) { 173 | // catch arrow keys and the Enter key 174 | var filteredOptions = this.filteredOptions(); 175 | var numOptions = this.cappedLength(filteredOptions); 176 | var newName; 177 | switch(e.keyCode){ 178 | 179 | case 38: // up arrow 180 | if (this.state.activeIndex>0) { 181 | this.setState({activeIndex: this.state.activeIndex-1}); 182 | } else { 183 | this.setState({activeIndex: numOptions-1}); 184 | } 185 | break; 186 | 187 | case 40: // down arrow 188 | this.setState({activeIndex: (this.state.activeIndex+1) % numOptions}); 189 | break; 190 | 191 | case 13: // enter 192 | var newIndex = caseInsensIndexOf(this.props.options, this.state.value); // may need this 193 | if (this.state.open) { 194 | e.preventDefault(); 195 | } 196 | if (this.state.activeIndex >= 0 && this.state.activeIndex < numOptions) { 197 | newIndex = this.state.activeIndex; 198 | newName = genGet(filteredOptions, this.state.activeIndex); 199 | this.setDropdownState(false); 200 | } else if (this.state.activeIndex === -1 && newIndex >= 0) { 201 | newName = genGet(this.props.options, newIndex); 202 | this.setDropdownState(false); 203 | } else { 204 | newIndex = this.state.activeIndex; 205 | newName = this.state.value; 206 | } 207 | this.sendSelect({value: newName, index: newIndex}); 208 | this.sendChange({value: newName}); 209 | this.setState({value: newName, activeIndex: -1}); 210 | break; 211 | 212 | } 213 | }, 214 | 215 | handleMouseEnter: function(index) { 216 | // when the mouse enters a dropdown menu item, set the active item to the item 217 | this.setState({activeIndex: index}); 218 | }, 219 | 220 | handleDropdownClick: function (e) { 221 | e.preventDefault(); 222 | 223 | this.setDropdownState(!this.state.open); 224 | }, 225 | 226 | handleOptionSelect: function(key, name) { 227 | // the user clicked on a dropdown menu item 228 | this.setDropdownState(false); 229 | this.sendSelect({value: name, index: this.state.activeIndex}); 230 | this.sendChange({value: name}); 231 | this.setState({value: name, activeIndex: -1}); 232 | }, 233 | 234 | sendChange: function(e) { 235 | if (this.props.onChange) { 236 | this.props.onChange(e); 237 | } 238 | }, 239 | 240 | sendSelect: function(e) { 241 | if (this.props.onSelect) { 242 | this.props.onSelect(e); 243 | } 244 | } 245 | 246 | 247 | }); 248 | 249 | module.exports = DropdownInput; 250 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // 2 | // react-dropdown-input 3 | // Displays a ReactBootstrap.Input element 4 | // with a ReactBootstrap.DropdownMenu of possible options. 5 | // 6 | 7 | "use strict"; 8 | 9 | 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; }; 10 | 11 | var React = require("react/addons"); 12 | var ReactBootstrap = require("react-bootstrap"); 13 | var joinClasses = require("react/lib/joinClasses"); 14 | var cx = require("classnames"); 15 | 16 | var BootstrapMixin = ReactBootstrap.BootstrapMixin; 17 | var DropdownStateMixin = ReactBootstrap.DropdownStateMixin; 18 | var DropdownMenu = ReactBootstrap.DropdownMenu; 19 | var Input = ReactBootstrap.Input; 20 | var MenuItem = ReactBootstrap.MenuItem; 21 | 22 | var defaultMaxText = "+# more not shown"; 23 | 24 | var defaultFilter = function defaultFilter(filterText, optionName) { 25 | // also optionIndex as third arg 26 | return optionName.toLowerCase().indexOf(filterText.toLowerCase()) >= 0; 27 | }; 28 | 29 | var genLength = function genLength(list) { 30 | // deal with both regular arrays and immutablejs objects, which have .count() instead of length 31 | return typeof list.count !== "undefined" ? list.count() : list.length; 32 | }; 33 | 34 | var genGet = function genGet(list, i) { 35 | // deal with both regular arrays and immutablejs objects, which have list.get(i) instead of list[i] 36 | return typeof list.get !== "undefined" ? list.get(i) : list[i]; 37 | }; 38 | 39 | var caseInsensIndexOf = function caseInsensIndexOf(list, str) { 40 | var lowerList = list.map(function (item) { 41 | return item.toLowerCase(); 42 | }); 43 | return lowerList.indexOf(str.toLowerCase()); 44 | }; 45 | 46 | var DropdownInput = React.createClass({ 47 | displayName: "DropdownInput", 48 | 49 | mixins: [BootstrapMixin, DropdownStateMixin], 50 | 51 | propTypes: { 52 | pullRight: React.PropTypes.bool, 53 | dropup: React.PropTypes.bool, 54 | defaultValue: React.PropTypes.string, 55 | menuClassName: React.PropTypes.string, 56 | max: React.PropTypes.number, 57 | maxText: React.PropTypes.string, 58 | onChange: React.PropTypes.func, 59 | onSelect: React.PropTypes.func, 60 | navItem: React.PropTypes.bool, 61 | options: React.PropTypes.oneOfType([React.PropTypes.object, React.PropTypes.array]).isRequired, 62 | filter: React.PropTypes.func, 63 | // the rest are to make eslint happy 64 | id: React.PropTypes.string, 65 | className: React.PropTypes.string, 66 | bsSize: React.PropTypes.string 67 | }, 68 | 69 | getInitialState: function getInitialState() { 70 | return { 71 | value: this.props.defaultValue || "", 72 | activeIndex: -1 73 | }; 74 | }, 75 | 76 | filteredOptions: function filteredOptions() { 77 | var filter = this.props.filter || defaultFilter; 78 | return this.props.options.filter(filter.bind(undefined, this.state.value)); 79 | }, 80 | 81 | cappedLength: function cappedLength(options) { 82 | var total = genLength(options); 83 | if (total > this.props.max) { 84 | // if it exceeded the max, we took an extra one off 85 | total = this.props.max - 1; 86 | } 87 | return total; 88 | }, 89 | 90 | render: function render() { 91 | var classes = { 92 | dropdown: true, 93 | open: this.state.open, 94 | dropup: this.props.dropup 95 | }; 96 | // you can provide a filter prop, which is a function(filterText, optionName, optionIndex) which should 97 | // return true to show option with the given name and index, given the input filterText. 98 | var filteredOptions = this.filteredOptions(); 99 | var numFiltered = genLength(filteredOptions); 100 | var maxMenuItem = null; 101 | var maxText = typeof this.props.maxText === "undefined" ? defaultMaxText : this.props.maxText; 102 | if (this.props.max && numFiltered > this.props.max) { 103 | // take an extra one off, to leave space for the maxText 104 | filteredOptions = filteredOptions.slice(0, this.props.max - 1); 105 | maxText = maxText.replace("#", numFiltered - this.props.max + 1); 106 | maxMenuItem = this.renderAsMenuItem(maxText, this.props.max, null, true); 107 | } 108 | var dropdown = null; 109 | if (numFiltered > 0) { 110 | dropdown = React.createElement( 111 | DropdownMenu, 112 | { 113 | className: this.props.menuClassName, 114 | ref: "menu", 115 | "aria-labelledby": this.props.id, 116 | pullRight: this.props.pullRight, 117 | key: 1, 118 | onSelect: null }, 119 | filteredOptions.map(this.renderAsMenuItem), 120 | maxMenuItem 121 | ); 122 | } 123 | return React.createElement( 124 | "div", 125 | { className: joinClasses(this.props.className, cx(classes)) }, 126 | React.createElement(Input, _extends({}, this.props, { 127 | menuClassName: null, 128 | options: null, 129 | type: "text", 130 | bsSize: this.props.bsSize, 131 | ref: "dropdownInput", 132 | onClick: this.handleDropdownClick, 133 | key: 0, 134 | navDropdown: this.props.navItem, 135 | navItem: null, 136 | pullRight: null, 137 | onSelect: null, 138 | onChange: this.handleInputChange, 139 | onKeyDown: this.handleKeyDown, 140 | dropup: null, 141 | value: this.state.value })), 142 | dropdown 143 | ); 144 | }, 145 | 146 | renderAsMenuItem: function renderAsMenuItem(item, index, options, disabled) { 147 | var start = item.toLowerCase().indexOf(this.state.value.toLowerCase()), 148 | end = start + this.state.value.length, 149 | part1 = item.slice(0, start), 150 | part2 = item.slice(start, end), 151 | part3 = item.slice(end); 152 | var classes = cx({ active: this.state.activeIndex === index, disabled: disabled === true }); 153 | if (disabled) { 154 | // don't highlight parts of disabled items, eg. the maxText 155 | part1 = item; 156 | part2 = null; 157 | part3 = null; 158 | } 159 | return React.createElement( 160 | MenuItem, 161 | { 162 | key: index, 163 | onSelect: this.handleOptionSelect.bind(this, index, item), 164 | className: classes, 165 | onMouseEnter: this.handleMouseEnter.bind(this, index) }, 166 | part1, 167 | React.createElement( 168 | "b", 169 | null, 170 | part2 171 | ), 172 | part3 173 | ); 174 | }, 175 | 176 | handleInputChange: function handleInputChange(e) { 177 | // the user changed the input text 178 | this.setState({ value: e.target.value, activeIndex: -1 }); 179 | this.setDropdownState(true); 180 | // fire the supplied onChange event. 181 | this.sendChange({ value: e.target.value }); 182 | }, 183 | 184 | handleKeyDown: function handleKeyDown(e) { 185 | // catch arrow keys and the Enter key 186 | var filteredOptions = this.filteredOptions(); 187 | var numOptions = this.cappedLength(filteredOptions); 188 | var newName; 189 | switch (e.keyCode) { 190 | 191 | case 38: 192 | // up arrow 193 | if (this.state.activeIndex > 0) { 194 | this.setState({ activeIndex: this.state.activeIndex - 1 }); 195 | } else { 196 | this.setState({ activeIndex: numOptions - 1 }); 197 | } 198 | break; 199 | 200 | case 40: 201 | // down arrow 202 | this.setState({ activeIndex: (this.state.activeIndex + 1) % numOptions }); 203 | break; 204 | 205 | case 13: 206 | // enter 207 | var newIndex = caseInsensIndexOf(this.props.options, this.state.value); // may need this 208 | if (this.state.open) { 209 | e.preventDefault(); 210 | } 211 | if (this.state.activeIndex >= 0 && this.state.activeIndex < numOptions) { 212 | newIndex = this.state.activeIndex; 213 | newName = genGet(filteredOptions, this.state.activeIndex); 214 | this.setDropdownState(false); 215 | } else if (this.state.activeIndex === -1 && newIndex >= 0) { 216 | newName = genGet(this.props.options, newIndex); 217 | this.setDropdownState(false); 218 | } else { 219 | newIndex = this.state.activeIndex; 220 | newName = this.state.value; 221 | } 222 | this.sendSelect({ value: newName, index: newIndex }); 223 | this.sendChange({ value: newName }); 224 | this.setState({ value: newName, activeIndex: -1 }); 225 | break; 226 | 227 | } 228 | }, 229 | 230 | handleMouseEnter: function handleMouseEnter(index) { 231 | // when the mouse enters a dropdown menu item, set the active item to the item 232 | this.setState({ activeIndex: index }); 233 | }, 234 | 235 | handleDropdownClick: function handleDropdownClick(e) { 236 | e.preventDefault(); 237 | 238 | this.setDropdownState(!this.state.open); 239 | }, 240 | 241 | handleOptionSelect: function handleOptionSelect(key, name) { 242 | // the user clicked on a dropdown menu item 243 | this.setDropdownState(false); 244 | this.sendSelect({ value: name, index: this.state.activeIndex }); 245 | this.sendChange({ value: name }); 246 | this.setState({ value: name, activeIndex: -1 }); 247 | }, 248 | 249 | sendChange: function sendChange(e) { 250 | if (this.props.onChange) { 251 | this.props.onChange(e); 252 | } 253 | }, 254 | 255 | sendSelect: function sendSelect(e) { 256 | if (this.props.onSelect) { 257 | this.props.onSelect(e); 258 | } 259 | } 260 | 261 | }); 262 | 263 | module.exports = DropdownInput; 264 | 265 | --------------------------------------------------------------------------------